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
+27 -54
View File
@@ -5,13 +5,16 @@ import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects'
import type { Card, CardStatus, Project } from '../types'
const route = useRoute()
const router = useRouter()
const cards = useCardsStore()
const inbox = useInboxStore()
const projects = useProjectsStore()
const projectId = Number(route.params.id)
@@ -38,9 +41,6 @@ const cancelButton = ref<HTMLButtonElement>()
const newText = ref('')
const submitting = ref(false)
const newInboxText = ref('')
const addingToInbox = ref(false)
const summary = computed(() => {
const total = cards.cards.length
if (total === 0) return 'No cards yet.'
@@ -53,10 +53,12 @@ const sortedCards = computed(() =>
)
// --- Kanban board --------------------------------------------------------
// One column per project status -- the inbox lives in the sidebar now, not
// here, though it's still a valid drag source/target (shared "kanban" group).
interface Column {
key: string
title: string
statusId: number | null
statusId: number
cards: Card[]
}
type ColumnChange = {
@@ -68,13 +70,11 @@ type ColumnChange = {
const board = ref<Column[]>([])
function buildColumns(): Column[] {
const defs: Omit<Column, 'cards'>[] = [
{ key: 'inbox', title: 'Inbox', statusId: null },
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })),
]
return defs.map((def) => ({
...def,
cards: cards.cards.filter((card) => card.status_id === def.statusId),
return statuses.value.map((s) => ({
key: `status-${s.id}`,
title: s.name,
statusId: s.id,
cards: cards.cards.filter((card) => card.status_id === s.id),
}))
}
@@ -86,12 +86,21 @@ function rebuildBoard() {
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in from another column) or `moved` (reordered within
// this one): persist this column's new id order. The source column, if any,
// is re-packed server-side. `removed` needs no action here.
if (change.added || change.moved) {
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id)))
async function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in, from another column here or from the sidebar's
// inbox) or `moved` (reordered within this one): persist this column's new
// id order. The column it left -- another status, or the inbox -- is
// re-packed server-side. `removed` needs no action here.
if (!change.added && !change.moved) return
actionError.value = null
try {
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// Either side of the drag could have been the inbox, so refresh both.
await Promise.all([inbox.load(), cards.load(projectId)])
}
}
@@ -218,21 +227,6 @@ async function onCreate() {
submitting.value = false
}
}
// New cards always land in the inbox (no status), so this just adds one.
async function onCreateInbox() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
actionError.value = null
try {
await cards.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
</script>
<template>
@@ -344,12 +338,7 @@ async function onCreateInbox() {
<p v-if="cards.loading && !cards.loaded" class="muted">Loading…</p>
<div v-else class="kanban">
<section
v-for="column in board"
:key="column.key"
class="kanban__col"
:class="{ 'kanban__col--inbox': column.statusId === null }"
>
<section v-for="column in board" :key="column.key" class="kanban__col">
<header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span>
@@ -368,22 +357,6 @@ async function onCreateInbox() {
<KanbanCard :card="element" />
</template>
</draggable>
<form
v-if="column.statusId === null"
class="kanban__new"
@submit.prevent="onCreateInbox"
>
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</section>
</div>
</div>