Prepare production and Coolify deployment with original exports and datetime pickers

This commit is contained in:
2026-09-09 17:59:20 -04:00
parent 574f29a68e
commit 64d7acc9c0
38 changed files with 1296 additions and 8 deletions
+1
View File
@@ -51,6 +51,7 @@ export const allowedImageTypes = [
export const allowedImageTypeSchema = z.enum(allowedImageTypes);
export const MAX_PHOTO_BYTES = 25 * 1024 * 1024;
export const exportPhotosInputSchema = z.object({ eventId: z.string().uuid(), filter: z.enum(["approved", "all"]).default("approved") });
export const eventSlugSchema = z
.string()
@@ -0,0 +1,15 @@
CREATE TABLE photo_exports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
requested_by text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
filter text NOT NULL CHECK (filter IN ('approved', 'all')),
status text NOT NULL DEFAULT 'pending',
total integer NOT NULL DEFAULT 0,
processed integer NOT NULL DEFAULT 0,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photo_exports_queue_idx ON photo_exports(status, created_at);
CREATE UNIQUE INDEX photo_exports_active_idx ON photo_exports(event_id, requested_by)
WHERE status IN ('pending', 'processing');
@@ -64,6 +64,13 @@
"when": 1788906000000,
"tag": "0008_email_webhooks",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788906100000,
"tag": "0009_photo_exports",
"breakpoints": true
}
]
}
+16
View File
@@ -0,0 +1,16 @@
import { eq, sql } from "drizzle-orm";
import { closeDb, getDb, platformAdministrators, user } from "@album/database";
// Promote an existing verified account, never create credentials or bypass email verification.
const userId = process.argv[2];
if (!userId) throw new Error("Usage: bun packages/database/src/bootstrap-admin.ts <verified-user-id>");
try {
await getDb().transaction(async tx => {
await tx.execute(sql`LOCK TABLE platform_administrators IN EXCLUSIVE MODE`);
if ((await tx.select({id:platformAdministrators.id}).from(platformAdministrators).limit(1)).length) throw new Error("An administrator already exists; use the admin UI instead");
const [existing] = await tx.select({id:user.id,verified:user.emailVerified}).from(user).where(eq(user.id,userId));
if (!existing?.verified) throw new Error("Account must exist and have verified email");
await tx.insert(platformAdministrators).values({userId,role:"super_admin"});
});
console.info("Initial administrator promoted");
} finally { await closeDb(); }
+12
View File
@@ -199,6 +199,18 @@ export const emailWebhookEvents = pgTable("email_webhook_events", {
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]);
export const photoExports = pgTable("photo_exports", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
requestedBy: text("requested_by").notNull().references(() => user.id, { onDelete: "cascade" }),
filter: text("filter").notNull(),
status: text("status").notNull().default("pending"),
total: integer("total").notNull().default(0),
processed: integer("processed").notNull().default(0),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
...timestamps,
}, (table) => [index("photo_exports_queue_idx").on(table.status, table.createdAt), uniqueIndex("photo_exports_active_idx").on(table.eventId,table.requestedBy).where(sql`${table.status} IN ('pending', 'processing')`), check("photo_exports_filter_check",sql`${table.filter} IN ('approved', 'all')`)]);
export const eventBanners = pgTable("event_banners", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
+24
View File
@@ -0,0 +1,24 @@
import { HeadBucketCommand, PutBucketCorsCommand, PutBucketLifecycleConfigurationCommand, S3Client } from "@aws-sdk/client-s3";
// Explicit deployment-only initialization, never run implicitly by web requests.
const origin = new URL(process.env.NEXT_PUBLIC_APP_URL!).origin;
if (!origin.startsWith("https://")) throw new Error("Storage CORS requires an HTTPS app origin");
const bucket = process.env.S3_BUCKET!;
if (!bucket || !process.env.S3_ACCESS_KEY || !process.env.S3_SECRET_KEY) throw new Error("Missing storage configuration");
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY },
requestChecksumCalculation: "WHEN_REQUIRED",
});
await client.send(new HeadBucketCommand({ Bucket: bucket }));
await client.send(new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: [{
AllowedOrigins: [origin], AllowedMethods: ["GET", "PUT", "HEAD"], AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "Content-Length"], MaxAgeSeconds: 3600,
}] } }));
await client.send(new PutBucketLifecycleConfigurationCommand({ Bucket: bucket, LifecycleConfiguration: { Rules: [
{ ID: "expire-exports", Status: "Enabled", Filter: { Prefix: "exports/" }, Expiration: { Days: 1 } },
{ ID: "abort-incomplete", Status: "Enabled", Filter: { Prefix: "" }, AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 } },
] } }));
console.log("Storage CORS and lifecycle configured.");
+3
View File
@@ -2,6 +2,9 @@ export {
createPresignedGetUrl,
createPresignedPutUrl,
deletePrefix,
deleteObject,
getSigningClient,
storageConfig,
ensureBucket,
getObjectBuffer,
headObject,
+12 -2
View File
@@ -1,6 +1,7 @@
import {
CreateBucketCommand,
DeleteObjectsCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
@@ -67,6 +68,11 @@ export async function ensureBucket() {
if (bucketReady) return;
const client = getSigningClient();
const { bucket } = storageConfig();
if (process.env.NODE_ENV === "production") {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
bucketReady = true;
return;
}
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
@@ -112,7 +118,7 @@ export async function createPresignedPutUrl(input: {
);
}
export async function createPresignedGetUrl(key: string) {
export async function createPresignedGetUrl(key: string, expiresIn = PRESIGN_GET_SECONDS) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
@@ -121,10 +127,14 @@ export async function createPresignedGetUrl(key: string) {
Bucket: bucket,
Key: key,
}),
{ expiresIn: PRESIGN_GET_SECONDS },
{ expiresIn },
);
}
export async function deleteObject(key: string) {
await getSigningClient().send(new DeleteObjectCommand({ Bucket: storageConfig().bucket, Key: key }));
}
export async function headObject(
key: string,
): Promise<HeadObjectCommandOutput | null> {