Prepare production and Coolify deployment with original exports and datetime pickers
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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.");
|
||||
@@ -2,6 +2,9 @@ export {
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
deletePrefix,
|
||||
deleteObject,
|
||||
getSigningClient,
|
||||
storageConfig,
|
||||
ensureBucket,
|
||||
getObjectBuffer,
|
||||
headObject,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user