From 5596e3fa19d3823672d27f31624fda14b21a1b45 Mon Sep 17 00:00:00 2001 From: belisards Date: Sun, 3 May 2026 16:18:34 -0300 Subject: [PATCH] feat(db): introduce item_claims for quantity-aware claims; supersede inline claim columns --- lib/db/index.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ lib/db/schema.ts | 27 ++++++++++++++++++++------- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/lib/db/index.ts b/lib/db/index.ts index ae1c3b6..dc7231b 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -113,6 +113,25 @@ export async function initializeDatabase() { ) `); + // Create item_claims table + sqlite.exec(` + CREATE TABLE IF NOT EXISTS item_claims ( + id TEXT PRIMARY KEY NOT NULL, + item_id TEXT NOT NULL, + guest_id TEXT NOT NULL, + quantity INTEGER DEFAULT 1 NOT NULL, + note TEXT, + is_purchased INTEGER DEFAULT 0 NOT NULL, + claimed_at INTEGER DEFAULT (unixepoch()) NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (item_id) REFERENCES wishlist_items(id) ON DELETE CASCADE, + FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE CASCADE, + UNIQUE(item_id, guest_id) + ) + `); + sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_item ON item_claims(item_id)'); + sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_guest ON item_claims(guest_id)'); + // Run migrations for existing databases try { const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>; @@ -157,6 +176,32 @@ export async function initializeDatabase() { } console.log('ℹ️ Legacy claim columns nullified (claimed_by_name / claimed_by_token)'); } + + // Move any existing inline claims into item_claims (forward-migration), then nullify the inline columns + const itemColsAfter = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>; + const stillHasClaimedByGuestId = itemColsAfter.some((c) => c.name === 'claimed_by_guest_id'); + if (stillHasClaimedByGuestId) { + sqlite.exec(` + INSERT INTO item_claims (id, item_id, guest_id, quantity, note, is_purchased, claimed_at, updated_at) + SELECT + lower(hex(randomblob(16))), + wi.id, + wi.claimed_by_guest_id, + 1, + wi.claimed_by_note, + wi.is_purchased, + COALESCE(wi.claimed_at, unixepoch()), + unixepoch() + FROM wishlist_items wi + WHERE wi.claimed_by_guest_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM item_claims ic + WHERE ic.item_id = wi.id AND ic.guest_id = wi.claimed_by_guest_id + ) + `); + sqlite.exec('UPDATE wishlist_items SET claimed_by_guest_id = NULL, claimed_by_note = NULL, claimed_at = NULL, is_purchased = 0'); + console.log('ℹ️ Migrated inline claims into item_claims and cleared inline claim columns'); + } } catch (migrationError) { console.log('Migration already applied or not needed'); } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1a40983..4c1fae6 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, unique } from 'drizzle-orm/sqlite-core'; import { createId } from '@paralleldrive/cuid2'; import { sql } from 'drizzle-orm'; @@ -36,17 +36,29 @@ export const wishlistItems = sqliteTable('wishlist_items', { url: string; }>>(), isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false), - - claimedByGuestId: text('claimed_by_guest_id').references(() => guests.id, { onDelete: 'set null' }), - claimedByNote: text('claimed_by_note'), - claimedAt: integer('claimed_at', { mode: 'timestamp' }), - isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false), - + // claim ownership lives in item_claims (1 item -> many claims; unique per (item, guest)) sortOrder: integer('sort_order').notNull().default(0), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), }); +export const itemClaims = sqliteTable( + 'item_claims', + { + id: text('id').primaryKey().$defaultFn(() => createId()), + itemId: text('item_id').notNull().references(() => wishlistItems.id, { onDelete: 'cascade' }), + guestId: text('guest_id').notNull().references(() => guests.id, { onDelete: 'cascade' }), + quantity: integer('quantity').notNull().default(1), + note: text('note'), + isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false), + claimedAt: integer('claimed_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + }, + (t) => ({ + uniqueItemGuest: unique('item_claims_item_guest_unique').on(t.itemId, t.guestId), + }) +); + export const settings = sqliteTable('settings', { id: text('id').primaryKey().$defaultFn(() => createId()), key: text('key').notNull().unique(), @@ -57,4 +69,5 @@ export const settings = sqliteTable('settings', { export type Wishlist = typeof wishlists.$inferSelect; export type WishlistItem = typeof wishlistItems.$inferSelect; export type Guest = typeof guests.$inferSelect; +export type ItemClaim = typeof itemClaims.$inferSelect; export type Setting = typeof settings.$inferSelect;