From 574f29a68e7ad296e673de8ab832e3270e4977e2 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 9 Sep 2026 15:44:00 -0400 Subject: [PATCH] Refine workspaces and event publishing; harden uploads and email delivery --- .env.example | 8 +- apps/web/src/app/admin/layout.tsx | 43 +-- apps/web/src/app/admin/platform-codes.tsx | 8 +- apps/web/src/app/admin/platform-users.tsx | 22 +- .../settings/deployment-settings-form.tsx | 8 +- apps/web/src/app/api/webhooks/resend/route.ts | 36 +++ .../dashboard/events/[id]/email-history.tsx | 23 ++ .../app/dashboard/events/[id]/event-audit.tsx | 52 +++- .../app/dashboard/events/[id]/event-notes.tsx | 19 +- .../dashboard/events/[id]/event-people.tsx | 65 ++++- .../dashboard/events/[id]/event-schedule.tsx | 48 ++++ .../events/[id]/event-settings-form.tsx | 179 +++++++++++- .../dashboard/events/[id]/moderation-grid.tsx | 232 +++++++++------- .../src/app/dashboard/events/[id]/page.tsx | 26 +- apps/web/src/app/dashboard/layout.tsx | 61 +---- apps/web/src/app/dashboard/page.tsx | 14 +- .../src/app/dashboard/people/group-people.tsx | 14 +- apps/web/src/app/e/[slug]/guest-gallery.tsx | 4 +- apps/web/src/app/e/[slug]/guest-upload.tsx | 48 +++- apps/web/src/app/e/[slug]/page.tsx | 46 +++- apps/web/src/app/globals.css | 13 +- apps/web/src/app/layout.tsx | 19 +- apps/web/src/app/page.tsx | 9 +- .../src/components/application-switcher.tsx | 59 ++++ apps/web/src/components/backend-shell.tsx | 52 ++++ apps/web/src/components/banner-upload.tsx | 74 +++++ apps/web/src/components/brand-mark.tsx | 15 +- .../components/double-sidebar-navigation.tsx | 259 ++++++++++++++++++ apps/web/src/components/event-map.tsx | 19 ++ apps/web/src/components/event-workspace.tsx | 13 +- apps/web/src/components/group-switcher.tsx | 57 ++-- apps/web/src/components/location-input.tsx | 82 ++++++ apps/web/src/components/site-header-bar.tsx | 179 +++++++----- apps/web/src/components/site-header.tsx | 28 +- apps/web/src/components/theme-provider.tsx | 7 +- apps/web/src/components/theme-toggle.tsx | 70 ++++- apps/web/src/components/top-bar-control.ts | 4 + apps/web/src/components/ui/button.tsx | 6 +- apps/web/src/components/ui/input.tsx | 2 +- apps/web/src/components/ui/native-select.tsx | 18 ++ apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/lib/event-lifecycle.test.ts | 25 ++ apps/web/src/lib/event-lifecycle.ts | 16 ++ apps/web/src/lib/event-status.ts | 7 + apps/web/src/lib/maps.ts | 11 + apps/web/src/lib/publishing.test.ts | 17 ++ apps/web/src/lib/publishing.ts | 5 + apps/web/src/lib/slug.test.ts | 6 + apps/web/src/lib/slug.ts | 3 +- apps/web/src/lib/workspace-navigation.ts | 51 ++++ apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/banners.ts | 48 ++++ apps/web/src/server/api/routers/event.ts | 73 ++++- apps/web/src/server/api/routers/guest.ts | 19 +- apps/web/src/server/api/routers/manager.ts | 237 +++++++++++++--- apps/web/src/server/api/routers/photos.ts | 24 +- apps/web/src/server/audit.ts | 16 +- apps/web/src/server/event-banner.ts | 21 ++ .../guest-notifications.integration.test.ts | 34 +++ apps/web/src/server/guest-notifications.ts | 25 ++ apps/web/src/server/location-search.test.ts | 22 ++ apps/web/src/server/location-search.ts | 43 +++ apps/web/src/server/membership.ts | 5 +- .../src/server/publishing.integration.test.ts | 110 ++++++++ apps/worker/package.json | 1 + apps/worker/src/index.ts | 49 +++- bun.lock | 5 + docs/email-delivery.md | 81 ++++++ docs/verification.md | 24 ++ package.json | 1 + packages/contracts/src/index.ts | 46 ++++ .../drizzle/0002_event_information.sql | 3 + .../drizzle/0003_event_coordinates.sql | 8 + .../drizzle/0004_event_banner_uploads.sql | 17 ++ .../drizzle/0005_publishing_preferences.sql | 6 + .../database/drizzle/0006_event_lifecycle.sql | 7 + .../database/drizzle/0007_email_outbox.sql | 9 + .../database/drizzle/0008_email_webhooks.sql | 8 + packages/database/drizzle/meta/_journal.json | 49 ++++ packages/database/package.json | 1 + packages/database/src/db.ts | 13 - packages/database/src/schema.ts | 59 ++++ packages/database/src/seed.ts | 13 +- packages/database/src/verify-migrations.ts | 21 ++ packages/email/package.json | 7 +- packages/email/src/index.test.ts | 16 ++ packages/email/src/index.ts | 55 +++- packages/email/src/logo.ts | 7 + packages/email/src/queue.ts | 44 +++ packages/email/src/webhooks.test.ts | 50 ++++ packages/email/src/webhooks.ts | 31 +++ packages/storage/src/s3.ts | 13 - scripts/send-email-previews.ts | 13 + 93 files changed, 2885 insertions(+), 535 deletions(-) create mode 100644 apps/web/src/app/api/webhooks/resend/route.ts create mode 100644 apps/web/src/app/dashboard/events/[id]/email-history.tsx create mode 100644 apps/web/src/app/dashboard/events/[id]/event-schedule.tsx create mode 100644 apps/web/src/components/application-switcher.tsx create mode 100644 apps/web/src/components/backend-shell.tsx create mode 100644 apps/web/src/components/banner-upload.tsx create mode 100644 apps/web/src/components/double-sidebar-navigation.tsx create mode 100644 apps/web/src/components/event-map.tsx create mode 100644 apps/web/src/components/location-input.tsx create mode 100644 apps/web/src/components/top-bar-control.ts create mode 100644 apps/web/src/components/ui/native-select.tsx create mode 100644 apps/web/src/lib/event-lifecycle.test.ts create mode 100644 apps/web/src/lib/event-lifecycle.ts create mode 100644 apps/web/src/lib/event-status.ts create mode 100644 apps/web/src/lib/maps.ts create mode 100644 apps/web/src/lib/publishing.test.ts create mode 100644 apps/web/src/lib/publishing.ts create mode 100644 apps/web/src/lib/workspace-navigation.ts create mode 100644 apps/web/src/server/api/routers/banners.ts create mode 100644 apps/web/src/server/event-banner.ts create mode 100644 apps/web/src/server/guest-notifications.integration.test.ts create mode 100644 apps/web/src/server/guest-notifications.ts create mode 100644 apps/web/src/server/location-search.test.ts create mode 100644 apps/web/src/server/location-search.ts create mode 100644 apps/web/src/server/publishing.integration.test.ts create mode 100644 docs/email-delivery.md create mode 100644 docs/verification.md create mode 100644 packages/database/drizzle/0002_event_information.sql create mode 100644 packages/database/drizzle/0003_event_coordinates.sql create mode 100644 packages/database/drizzle/0004_event_banner_uploads.sql create mode 100644 packages/database/drizzle/0005_publishing_preferences.sql create mode 100644 packages/database/drizzle/0006_event_lifecycle.sql create mode 100644 packages/database/drizzle/0007_email_outbox.sql create mode 100644 packages/database/drizzle/0008_email_webhooks.sql create mode 100644 packages/database/src/verify-migrations.ts create mode 100644 packages/email/src/index.test.ts create mode 100644 packages/email/src/logo.ts create mode 100644 packages/email/src/queue.ts create mode 100644 packages/email/src/webhooks.test.ts create mode 100644 packages/email/src/webhooks.ts create mode 100644 scripts/send-email-previews.ts diff --git a/.env.example b/.env.example index cce5707..6690ecf 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,15 @@ AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= EMAIL_PROVIDER=mailpit -EMAIL_FROM=Album +EMAIL_FROM=Manyangles SMTP_HOST=127.0.0.1 SMTP_PORT=1027 RESEND_API_KEY= -RESEND_FROM=Album +# Signing secret for POST /api/webhooks/resend (not the API key). +RESEND_WEBHOOK_SECRET= +# Production: set EMAIL_PROVIDER=resend and EMAIL_FROM to a verified Resend domain. +# Run the worker with the same email configuration. Never use production for tests. +RESEND_FROM=Manyangles S3_ENDPOINT=http://127.0.0.1:3900 S3_PUBLIC_ENDPOINT=http://127.0.0.1:3900 diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 3b84367..7a4c0e7 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -1,39 +1,16 @@ import { redirect } from "next/navigation"; -import { headers } from "next/headers"; -import Link from "next/link"; -import { auth } from "@/server/auth"; -import { getPlatformRole } from "@/server/roles"; -import { Button } from "@/components/ui/button"; +import { createServerCaller } from "@/trpc/server"; +import { BackendShell } from "@/components/backend-shell"; -import { DashboardTabBar } from "@/components/dashboard-tab-bar"; - -export default async function AdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session) redirect("/sign-in?callbackURL=/admin"); - const role = await getPlatformRole(session.user.id); - if (!role) redirect("/dashboard"); +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const caller = await createServerCaller(); + const viewer = await caller.viewer.me(); + if (!viewer.session) redirect("/sign-in?callbackURL=/admin"); + if (!viewer.platformRole) redirect("/dashboard"); return ( - <> -
-
- - - -
-
{children}
-
- - + +
{children}
+
); } diff --git a/apps/web/src/app/admin/platform-codes.tsx b/apps/web/src/app/admin/platform-codes.tsx index e8c69bd..1e843e1 100644 --- a/apps/web/src/app/admin/platform-codes.tsx +++ b/apps/web/src/app/admin/platform-codes.tsx @@ -1,5 +1,7 @@ "use client"; +import { NativeSelect } from "@/components/ui/native-select"; + import { useState } from "react"; import { toast } from "sonner"; import { api } from "@/trpc/react"; @@ -78,8 +80,8 @@ export function PlatformCodes({ ) : null} {groups.length > 0 ? (
- + : null} +
)} + ; +} diff --git a/apps/web/src/app/dashboard/events/[id]/event-audit.tsx b/apps/web/src/app/dashboard/events/[id]/event-audit.tsx index f00c163..925ed90 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-audit.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-audit.tsx @@ -1,6 +1,11 @@ "use client"; import { api } from "@/trpc/react"; +import { useState } from "react"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ImageIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Card, CardContent, @@ -10,7 +15,9 @@ import { } from "@/components/ui/card"; export function EventAudit({ eventId }: { eventId: string }) { - const audit = api.manager.audit.useQuery({ eventId }); + const [page, setPage] = useState(0); + const [category, setCategory] = useState<"all" | "event" | "photo" | "note" | "guest" | "gallery" | "submission">("all"); + const audit = api.manager.audit.useQuery({ eventId, page, category }); return ( @@ -18,15 +25,44 @@ export function EventAudit({ eventId }: { eventId: string }) { Recent changes for this event. +
+ + + +
+ {audit.isLoading ?

Loading activity…

: null} + {audit.isError ?

Could not load activity.

: null} + {audit.data?.length === 0 ?

No activity recorded yet.

: null}
    {(audit.data ?? []).map((row) => ( -
  • - - {row.action} · {row.subjectType} - - - {new Date(row.createdAt).toLocaleString()} - +
  • +
    +

    {row.actorName ?? String(row.metadata.actorName ?? (row.actorUserId ? "Deleted account" : "System"))} · {row.action.replaceAll(".", " ")}

    + +
    +

    {row.subjectLabel}

    +

    {row.subjectType}: {row.subjectId}

    + {row.assetUrl ? ( + + + + + + + Referenced photo + {row.subjectLabel} + + Photo referenced by this audit entry + + + ) : null} + {Object.keys(row.metadata).length ?
    + {Object.entries(row.metadata).filter(([key]) => key !== "actorName" && key !== "subjectLabel").map(([key, value]) =>
    {key}
    {String(value ?? "—")}
    )} +
    : null}
  • ))}
diff --git a/apps/web/src/app/dashboard/events/[id]/event-notes.tsx b/apps/web/src/app/dashboard/events/[id]/event-notes.tsx index cfbc687..699ece3 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-notes.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-notes.tsx @@ -1,6 +1,9 @@ "use client"; import { api } from "@/trpc/react"; +import { Button } from "@/components/ui/button"; +import { CheckIcon, EyeOffIcon } from "lucide-react"; +import { toast } from "sonner"; import { Card, CardContent, @@ -15,7 +18,12 @@ import { EmptyTitle, } from "@/components/ui/empty"; -export function EventNotes({ eventId }: { eventId: string }) { +export function EventNotes({ eventId, canManage }: { eventId: string; canManage: boolean }) { + const utils = api.useUtils(); + const moderate = api.manager.moderateNote.useMutation({ + onSuccess: () => { void utils.manager.notes.invalidate({ eventId }); }, + onError: (error) => toast.error(error.message), + }); const notes = api.manager.notes.useQuery({ eventId }); const withNotes = (notes.data ?? []).filter((guest) => guest.note); @@ -24,7 +32,7 @@ export function EventNotes({ eventId }: { eventId: string }) { Notes - Messages guests left for the event people. + Messages guests left for the event people. Approved notes appear publicly only when note publishing is enabled in Settings. @@ -33,7 +41,7 @@ export function EventNotes({ eventId }: { eventId: string }) { No notes yet - Guests can leave a note when they upload. + Guests can send notes with or without photos. @@ -45,6 +53,11 @@ export function EventNotes({ eventId }: { eventId: string }) { {guest.displayName ?? "Anonymous"}

{guest.note}

+ {canManage ? : null} ))} diff --git a/apps/web/src/app/dashboard/events/[id]/event-people.tsx b/apps/web/src/app/dashboard/events/[id]/event-people.tsx index ca9eb35..af7d5ab 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-people.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-people.tsx @@ -1,5 +1,7 @@ "use client"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + import { useState } from "react"; import { toast } from "sonner"; import type { EventRole } from "@album/contracts"; @@ -7,6 +9,10 @@ import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { MailIcon } from "lucide-react"; +import { effectiveEvent } from "@/lib/event-lifecycle"; +import { galleryIsPublic } from "@/lib/publishing"; +import { EmailHistory } from "./email-history"; import { Card, CardContent, @@ -16,6 +22,7 @@ import { } from "@/components/ui/card"; const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"]; +const roleLabels: Record = { owner: "Owner", manager: "Manager", moderator: "Moderator", viewer: "Viewer" }; export function EventPeople({ eventId, @@ -28,6 +35,16 @@ export function EventPeople({ }) { const utils = api.useUtils(); const members = api.manager.members.useQuery({ eventId }); + const guests = api.manager.guests.useQuery({ eventId }, { refetchInterval: 5000 }); + const [guestSearch, setGuestSearch] = useState(""); + const [preview, setPreview] = useState(false); + const emailPreview = api.manager.emailPreview.useQuery({ eventId }, { enabled: preview }); + const notify = api.manager.notifyGuests.useMutation({ + onSuccess: async (result) => { toast.success(`${result.queued} emails queued`); setPreview(false); await utils.manager.guests.invalidate({ eventId }); }, + onError: (error) => toast.error(error.message), + }); + const previouslyContacted = new Set((guests.data ?? []).filter((guest) => guest.email && (guest.notifiedAt || guest.notificationClaimedAt)).map((guest) => guest.email!.toLowerCase())); + const eligible = new Set((guests.data ?? []).filter((guest) => guest.email && guest.notifyWhenReady && !previouslyContacted.has(guest.email.toLowerCase())).map((guest) => guest.email!.toLowerCase())).size; const [email, setEmail] = useState(""); const [role, setRole] = useState("manager"); const setMember = api.manager.setMember.useMutation({ @@ -55,10 +72,33 @@ export function EventPeople({ People - The couple can both be owners. Managers run the day. Moderators review photos. + Guests attend or receive gallery notifications. Accounts have permission to manage this event; attending does not grant account access. +

Guests

+ setGuestSearch(e.target.value)} /> + {guests.isLoading ?

Loading guests…

: guests.isError ?

Could not load guests.

: !guests.data?.length ?

No guests yet. Guests appear when they register their details or contribute.

: null} +
    + {(guests.data ?? []).filter((guest) => `${guest.displayName ?? ""} ${guest.email ?? ""}`.toLowerCase().includes(guestSearch.toLowerCase())).map((guest) =>
  • +

    {guest.displayName ?? "Anonymous guest"}

    {guest.email ?? "No email provided"}

    + {guest.notifiedAt ? `Emailed ${new Date(guest.notifiedAt).toLocaleDateString()}` : guest.notificationClaimedAt ? "Delivery pending / needs review" : guest.email && guest.notifyWhenReady ? "Opted into gallery email" : "Not subscribed"} +
  • )} +
+ {event.data?.permissions.includes("gallery.release") ?
+ +

Complete the event and make the gallery public first. Only opted-in guests who have not already been emailed are eligible; repeated addresses are deduplicated.

+ {preview ?
+

The gallery for {event.data.title} is ready

+ {emailPreview.data ?