Make the inbox global instead of per-project

A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:22:33 +01:00
co-authored by Claude Sonnet 5
parent c82bdbbf0e
commit 4ee0f24078
19 changed files with 957 additions and 674 deletions
+103 -3
View File
@@ -1,11 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import draggable from 'vuedraggable'
import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue'
const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
const title = ref('')
const createError = ref<ApiError | null>(null)
@@ -15,10 +24,11 @@ const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(() => {
if (!projects.loaded) void load()
if (!projects.loaded) void loadProjects()
if (!inbox.loaded) void loadInbox()
})
async function load() {
async function loadProjects() {
loadError.value = null
try {
await projects.fetchProjects()
@@ -40,6 +50,58 @@ async function onCreate() {
submitting.value = false
}
}
// --- Inbox: a global holding area, not tied to any project. Cards drag in
// and out of it from a project's kanban board (shared "kanban" group).
const newInboxText = ref('')
const addingToInbox = ref(false)
const inboxError = ref<string | null>(null)
type ColumnChange = {
added?: { element: Card; newIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
async function loadInbox() {
inboxError.value = null
try {
await inbox.load()
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not load the inbox.'
}
}
async function onCreateInboxCard() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
inboxError.value = null
try {
await inbox.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
async function onInboxChange(change: ColumnChange) {
if (!change.added && !change.moved) return
inboxError.value = null
try {
await reorderColumn(null, null, inbox.cards.map((c) => c.id))
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// The card may have come from (or gone to) the project currently open.
const openProjectId = route.name === 'project' ? Number(route.params.id) : null
await Promise.all([
loadInbox(),
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
])
}
}
</script>
<template>
@@ -105,5 +167,43 @@ async function onCreate() {
</button>
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
</form>
<hr class="sidebar__divider" />
<p class="sidebar__heading">Inbox</p>
<p v-if="inboxError" class="sidebar__note form-error">{{ inboxError }}</p>
<p v-else-if="inbox.loading && !inbox.loaded" class="sidebar__note muted">Loading</p>
<template v-else>
<!-- Always rendered (even empty) so it stays a valid drop target for a
card dragged out of a project's kanban board. -->
<p v-if="inbox.cards.length === 0" class="sidebar__note muted">Nothing in the inbox.</p>
<draggable
:list="inbox.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards sidebar__inbox-cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="onInboxChange"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
</template>
<form class="kanban__new" @submit.prevent="onCreateInboxCard">
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</aside>
</template>