diff --git a/scripts/push-11-tags-migration.ts b/scripts/push-11-tags-migration.ts new file mode 100644 index 0000000..f3f000e --- /dev/null +++ b/scripts/push-11-tags-migration.ts @@ -0,0 +1,55 @@ +// PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS + +// 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via +// SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04). +// Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL) +import postgres from "postgres"; + +async function push() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL environment variable is required"); + process.exit(1); + } + + const client = postgres(databaseUrl); + + try { + console.log("Pushing tags table migration..."); + + await client` + CREATE TABLE IF NOT EXISTS tags ( + id text PRIMARY KEY, + entity_type text NOT NULL, + entity_id text NOT NULL, + name text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL + ) + `; + + await client` + CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique + ON tags (entity_type, entity_id, name) + `; + + await client` + CREATE INDEX IF NOT EXISTS tags_entity_idx + ON tags (entity_type, entity_id) + `; + + console.log("✓ tags table created successfully"); + process.exit(0); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message.includes("already exists")) { + console.log("✓ tags table already exists (skipped)"); + process.exit(0); + } + console.error("Error pushing migration:", err.message); + } else { + console.error("Unknown error:", err); + } + process.exit(1); + } +} + +push(); diff --git a/src/db/migrations/0006_add_tags_table.sql b/src/db/migrations/0006_add_tags_table.sql new file mode 100644 index 0000000..7bab57c --- /dev/null +++ b/src/db/migrations/0006_add_tags_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE "tags" ( + "id" text PRIMARY KEY NOT NULL, + "entity_type" text NOT NULL, + "entity_id" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "tags_entity_name_unique" ON "tags" USING btree ("entity_type","entity_id","name"); +--> statement-breakpoint +CREATE INDEX "tags_entity_idx" ON "tags" USING btree ("entity_type","entity_id"); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 8a21a81..9397988 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781151516000, "tag": "0001_add_services_table", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1781442000000, + "tag": "0006_add_tags_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 190e314..5430e88 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6,6 +6,8 @@ import { timestamp, boolean, primaryKey, + uniqueIndex, + index, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -110,6 +112,33 @@ export const comments = pgTable("comments", { .defaultNow(), }); +// ============ TAGS (polymorphic — services now, leads in Phase 14) ============ +// entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are +// separate pools even though they share this table. No `color` column — badge +// color is derived deterministically from `name` via hash (D-07). +export const tags = pgTable( + "tags", + { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14) + entity_id: text("entity_id").notNull(), + name: text("name").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + entityTagUnique: uniqueIndex("tags_entity_name_unique").on( + t.entity_type, + t.entity_id, + t.name + ), + entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id), + }) +); + // ============ PAYMENTS ============ export const payments = pgTable("payments", { id: text("id") @@ -460,6 +489,10 @@ export const commentsRelations = relations(comments, (_) => ({ // Polymorphic: no direct FK relation — entity_type + entity_id used at query time })); +export const tagsRelations = relations(tags, (_) => ({ + // Polymorphic: no direct FK relation — entity_type + entity_id used at query time +})); + export const paymentsRelations = relations(payments, ({ one }) => ({ project: one(projects, { fields: [payments.project_id], @@ -578,6 +611,8 @@ export type Deliverable = typeof deliverables.$inferSelect; export type NewDeliverable = typeof deliverables.$inferInsert; export type Comment = typeof comments.$inferSelect; export type NewComment = typeof comments.$inferInsert; +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; export type Payment = typeof payments.$inferSelect; export type NewPayment = typeof payments.$inferInsert; export type Document = typeof documents.$inferSelect;