Refine workspaces and event publishing; harden uploads and email delivery
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user