Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "events" ADD COLUMN "location" text;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "banner_photo_id" uuid;
@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "latitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "longitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_coordinates_check" CHECK (
("latitude" IS NULL AND "longitude" IS NULL) OR
("latitude" IS NOT NULL AND "longitude" IS NOT NULL AND "latitude" BETWEEN -90 AND 90 AND "longitude" BETWEEN -180 AND 180)
);
@@ -0,0 +1,17 @@
ALTER TABLE "events" ADD COLUMN "custom_banner_id" uuid;
--> statement-breakpoint
CREATE TABLE "event_banners" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"event_id" uuid NOT NULL REFERENCES "events"("id") ON DELETE CASCADE,
"original_key" text NOT NULL,
"display_key" text,
"content_type" text NOT NULL,
"byte_size" integer NOT NULL,
"status" text NOT NULL DEFAULT 'uploading',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX "event_banners_event_idx" ON "event_banners" ("event_id");
--> statement-breakpoint
CREATE INDEX "event_banners_status_idx" ON "event_banners" ("status");
@@ -0,0 +1,6 @@
ALTER TABLE events ADD COLUMN notes_policy text NOT NULL DEFAULT 'never' CHECK (notes_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN gallery_policy text NOT NULL DEFAULT 'approved' CHECK (gallery_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN show_photo_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_submitter_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_note_stats boolean NOT NULL DEFAULT false;
ALTER TABLE guests ADD COLUMN note_approved boolean NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
ALTER TABLE events ADD COLUMN publish_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_open_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_close_at timestamptz;
ALTER TABLE events ADD COLUMN gallery_visible_at timestamptz;
ALTER TABLE events ADD COLUMN notes_visible_at timestamptz;
ALTER TABLE events ADD COLUMN completed_at timestamptz;
ALTER TABLE guests ADD COLUMN notification_claimed_at timestamptz;
@@ -0,0 +1,9 @@
CREATE TABLE email_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
recipient text NOT NULL, payload jsonb NOT NULL, provider text NOT NULL,
status text NOT NULL DEFAULT 'pending', attempts integer NOT NULL DEFAULT 0,
first_attempt_at timestamptz, next_attempt_at timestamptz NOT NULL DEFAULT now(),
provider_id text, last_error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX email_deliveries_event_recipient_idx ON email_deliveries(event_id, recipient);
CREATE INDEX email_deliveries_queue_idx ON email_deliveries(status, next_attempt_at);
@@ -0,0 +1,8 @@
CREATE TABLE "email_webhook_events" (
"id" text PRIMARY KEY,
"provider_id" text NOT NULL,
"outcome" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"received_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "email_webhook_events_provider_idx" ON "email_webhook_events" ("provider_id");
@@ -15,6 +15,55 @@
"when": 1788819000000,
"tag": "0001_groups_roles_guests",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788905400000,
"tag": "0002_event_information",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1788905500000,
"tag": "0003_event_coordinates",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788905600000,
"tag": "0004_event_banner_uploads",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788905700000,
"tag": "0005_publishing_preferences",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1788905800000,
"tag": "0006_event_lifecycle",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788905900000,
"tag": "0007_email_outbox",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788906000000,
"tag": "0008_email_webhooks",
"breakpoints": true
}
]
}
+1
View File
@@ -18,6 +18,7 @@
"seed": "bun src/seed.ts"
},
"dependencies": {
"@album/contracts": "workspace:*",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.7"
-13
View File
@@ -1,21 +1,8 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as appSchema from "./schema";
import * as authSchema from "./auth-schema";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const schema = { ...appSchema, ...authSchema };
type PostgresClient = ReturnType<typeof postgres>;
+59
View File
@@ -1,5 +1,7 @@
import {
boolean,
check,
doublePrecision,
index,
integer,
jsonb,
@@ -138,21 +140,76 @@ export const events = pgTable(
slug: text("slug").notNull(),
title: text("title").notNull(),
description: text("description"),
location: text("location"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
bannerPhotoId: uuid("banner_photo_id"),
customBannerId: uuid("custom_banner_id"),
startsAt: timestamp("starts_at", { withTimezone: true }),
endsAt: timestamp("ends_at", { withTimezone: true }),
status: eventStatus("status").notNull().default("draft"),
listed: boolean("listed").notNull().default(false),
uploadEnabled: boolean("upload_enabled").notNull().default(true),
galleryReleasedAt: timestamp("gallery_released_at", { withTimezone: true }),
publishAt: timestamp("publish_at", { withTimezone: true }),
submissionsOpenAt: timestamp("submissions_open_at", { withTimezone: true }),
submissionsCloseAt: timestamp("submissions_close_at", { withTimezone: true }),
galleryVisibleAt: timestamp("gallery_visible_at", { withTimezone: true }),
notesVisibleAt: timestamp("notes_visible_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
notesPolicy: text("notes_policy").$type<"automatic" | "approved" | "never">().notNull().default("never"),
galleryPolicy: text("gallery_policy").$type<"automatic" | "approved" | "never">().notNull().default("approved"),
showPhotoStats: boolean("show_photo_stats").notNull().default(false),
showSubmitterStats: boolean("show_submitter_stats").notNull().default(false),
showNoteStats: boolean("show_note_stats").notNull().default(false),
...timestamps,
},
(table) => [
uniqueIndex("events_slug_idx").on(table.slug),
check("events_notes_policy_check", sql`${table.notesPolicy} IN ('automatic', 'approved', 'never')`),
check("events_gallery_policy_check", sql`${table.galleryPolicy} IN ('automatic', 'approved', 'never')`),
check("events_coordinates_check", sql`(${table.latitude} IS NULL AND ${table.longitude} IS NULL) OR (${table.latitude} IS NOT NULL AND ${table.longitude} IS NOT NULL AND ${table.latitude} BETWEEN -90 AND 90 AND ${table.longitude} BETWEEN -180 AND 180)`),
index("events_group_id_idx").on(table.groupId),
index("events_listed_idx").on(table.listed, table.status),
],
);
export const emailDeliveries = pgTable("email_deliveries", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
recipient: text("recipient").notNull(),
payload: jsonb("payload").$type<import("@album/contracts").EmailPayload>().notNull(),
provider: text("provider").notNull(),
status: text("status").notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true }),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
providerId: text("provider_id"),
lastError: text("last_error"),
...timestamps,
}, (table) => [uniqueIndex("email_deliveries_event_recipient_idx").on(table.eventId, table.recipient), index("email_deliveries_queue_idx").on(table.status, table.nextAttemptAt)]);
// Provider callbacks may precede the worker's providerId write. Keep a minimal
// inbox independently, without storing recipient addresses or raw payloads.
export const emailWebhookEvents = pgTable("email_webhook_events", {
id: text("id").primaryKey(),
providerId: text("provider_id").notNull(),
outcome: text("outcome").notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]);
export const eventBanners = pgTable("event_banners", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
originalKey: text("original_key").notNull(),
displayKey: text("display_key"),
contentType: text("content_type").notNull(),
byteSize: integer("byte_size").notNull(),
status: text("status").notNull().default("uploading"),
...timestamps,
}, (table) => [index("event_banners_event_idx").on(table.eventId), index("event_banners_status_idx").on(table.status)]);
export const eventMemberships = pgTable(
"event_memberships",
{
@@ -272,7 +329,9 @@ export const guests = pgTable(
email: text("email"),
notifyWhenReady: boolean("notify_when_ready").notNull().default(false),
notifiedAt: timestamp("notified_at", { withTimezone: true }),
notificationClaimedAt: timestamp("notification_claimed_at", { withTimezone: true }),
note: text("note"),
noteApproved: boolean("note_approved").notNull().default(false),
tokenHash: text("token_hash").notNull(),
...timestamps,
},
+10 -3
View File
@@ -117,14 +117,15 @@ if (group) {
.values({
groupId: group.id,
slug: "demo",
title: "Demo gathering",
title: "Riverhead Raceway Championship Night",
description:
"A sample event so you can try guest uploads and host moderation.",
"A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
status: "published",
listed: true,
uploadEnabled: true,
galleryReleasedAt: new Date(),
startsAt: new Date(),
startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
})
.returning();
console.info("Seeded published event /e/demo");
@@ -133,8 +134,14 @@ if (group) {
.update(events)
.set({
groupId: group.id,
title: "Riverhead Raceway Championship Night",
description:
"A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
listed: true,
uploadEnabled: true,
galleryReleasedAt: event.galleryReleasedAt ?? new Date(),
startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
status: "published",
updatedAt: new Date(),
})
@@ -0,0 +1,21 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
const url = new URL(process.env.DATABASE_URL ?? "postgres://album:album@localhost:5439/album");
if (!["localhost", "127.0.0.1"].includes(url.hostname)) throw new Error("Clean migration verification is local-only");
const admin = postgres(url.toString(), { max: 1 });
const databaseName = `manyangles_verify_${crypto.randomUUID().replaceAll("-", "")}`;
await admin`CREATE DATABASE ${admin(databaseName)}`;
url.pathname = `/${databaseName}`;
const client = postgres(url.toString(), { max: 1 });
try {
await migrate(drizzle(client), { migrationsFolder: new URL("../drizzle", import.meta.url).pathname });
const rows = await client`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('email_deliveries', 'events', 'guests', 'event_banners')`;
if (rows.length !== 4) throw new Error("Expected lifecycle/outbox tables were not created");
console.info("Clean-database migrations passed");
} finally {
await client.end();
await admin`DROP DATABASE ${admin(databaseName)}`;
await admin.end();
}