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
+3
View File
@@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: "ok" });
}
@@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { Download } from "lucide-react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
export function EventExports({eventId}:{eventId:string}) {
const [filter,setFilter] = useState<"approved"|"all">("approved");
const history = api.manager.exports.useQuery({eventId},{refetchInterval:3000});
const request = api.manager.requestExport.useMutation({onSuccess:()=>{toast.success("Originals export queued");void history.refetch();},onError:e=>toast.error(e.message)});
const download = api.manager.downloadExport.useMutation({onSuccess:result=>{window.location.assign(result.url);},onError:e=>toast.error(e.message)});
return <section className="flex flex-col gap-3" aria-label="Export originals">
<h2 className="text-lg font-semibold">Export originals</h2>
<p className="text-sm text-muted-foreground">Full-quality files, unchangedincluding original metadata. ZIPs expire after 24 hours. Includes processed gallery photos, not standalone banners. Maximum 10,000 photos / 20 GB per export.</p>
<div className="flex flex-wrap items-center gap-2">
<Select value={filter} onValueChange={value=>setFilter(value as "approved"|"all")}>
<SelectTrigger aria-label="Photos to export"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup><SelectItem value="approved">Approved photos</SelectItem><SelectItem value="all">All photos, including private</SelectItem></SelectGroup></SelectContent>
</Select>
<Button variant="outline" disabled={request.isPending || history.data?.some(job=>job.status==="pending"||job.status==="processing")} onClick={()=>request.mutate({eventId,filter})}><Download data-icon="inline-start" />Export originals</Button>
</div>
{history.isError ? <p role="alert">Could not load exports.</p> : null}
<ul className="flex flex-col gap-2" aria-live="polite">{history.data?.map(job=><li key={job.id} className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm">{job.filter==="all"?"All photos":"Approved photos"} · {job.status.charAt(0).toUpperCase()+job.status.slice(1)}{job.total>0?` · ${job.processed}/${job.total}`:""}{job.status==="failed"?" — empty album, size limit, or processing error; try a new export.":""}</span>
{job.status==="ready" ? <Button variant="outline" disabled={download.isPending} onClick={()=>download.mutate({eventId,exportId:job.id})}><Download data-icon="inline-start" />Download ZIP</Button> : null}
</li>)}</ul>
</section>;
}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
@@ -36,7 +36,7 @@ export function EventSchedule({ eventId }: { eventId: string }) {
}}>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
{dateFields.map(([key, label]) => <Field key={key}><FieldLabel htmlFor={`schedule-${key}`}>{label}</FieldLabel>
<Input id={`schedule-${key}`} type="datetime-local" value={dates[key] ?? localDate(event.data![key])} onChange={(e) => setDates({ ...dates, [key]: e.target.value })} />
<DateTimePicker id={`schedule-${key}`} ariaLabel={label} disabled={update.isPending} value={dates[key] ?? localDate(event.data![key])} onValueChange={(value) => setDates(previous => ({ ...previous, [key]: value }))} />
</Field>)}
</FieldGroup>
<p className="text-sm text-muted-foreground">Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.</p>
@@ -11,6 +11,7 @@ import { EventAudit } from "./event-audit";
import { EventSchedule } from "./event-schedule";
import { eventStatusLabel } from "@/lib/event-status";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { EventExports } from "./event-exports";
export default async function EventDashboardPage({
params,
@@ -37,12 +38,15 @@ export default async function EventDashboardPage({
id: "photos",
label: "Photos",
content: (
<div className="flex flex-col gap-6">
{canSettings && event.permissions.includes("photos.private.read") ? <EventExports eventId={event.id} /> : null}
<ModerationGrid
eventId={event.id}
canModerate={canModerate}
canDelete={event.permissions.includes("photos.delete")}
canPrivate={event.permissions.includes("photos.private.read")}
/>
</div>
),
},
...(canSettings
+176
View File
@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaults = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
className={cn(
"group/calendar p-3",
className,
)}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaults.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaults.months),
month: cn("flex w-[15.75rem] flex-col gap-4", defaults.month),
nav: cn(
"absolute inset-x-0 top-0 flex items-center justify-between",
defaults.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-9 p-0",
defaults.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-9 p-0",
defaults.button_next,
),
month_caption: cn(
"flex h-9 items-center justify-center px-9",
defaults.month_caption,
),
caption_label: cn(
"select-none text-sm font-bold",
captionLayout !== "label" &&
"flex h-8 items-center gap-1 rounded-md pl-2 pr-1",
defaults.caption_label,
),
dropdowns: cn(
"flex h-9 items-center justify-center gap-1.5 text-sm font-medium",
defaults.dropdowns,
),
dropdown_root: cn(
"relative rounded-md border border-input focus-within:ring-2 focus-within:ring-ring",
defaults.dropdown_root,
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaults.dropdown,
),
month_grid: cn("w-full border-collapse", defaults.month_grid),
weekdays: cn("grid grid-cols-7", defaults.weekdays),
weekday: cn(
"flex size-9 select-none items-center justify-center rounded-md text-center text-xs font-semibold text-muted-foreground",
defaults.weekday,
),
week: cn("mt-2 grid grid-cols-7", defaults.week),
day: cn(
"relative size-9 p-0 text-center",
defaults.day,
),
today: cn(
"rounded-md bg-accent text-accent-foreground",
defaults.today,
),
outside: cn(
"text-muted-foreground opacity-50",
defaults.outside,
),
disabled: cn(
"pointer-events-none text-muted-foreground opacity-40",
defaults.disabled,
),
hidden: cn("invisible", defaults.hidden),
range_start: cn("rounded-l-md bg-accent", defaults.range_start),
range_middle: cn("rounded-none bg-accent", defaults.range_middle),
range_end: cn("rounded-r-md bg-accent", defaults.range_end),
...classNames,
}}
components={{
Chevron: ({ className: iconClassName, orientation, ...iconProps }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
}
return (
<ChevronDownIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
},
DayButton: CalendarDayButton,
...components,
}}
{...props}
/>
);
}
export function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<button
ref={ref}
type="button"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-9 rounded-md p-0 font-medium data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground",
className,
)}
{...props}
/>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import { CalendarIcon, X } from "lucide-react";
import { format, isValid, parse } from "date-fns";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
function parseFormDate(value?: string) {
if (!value) return undefined;
const date = parse(value, "yyyy-MM-dd", new Date());
return isValid(date) ? date : undefined;
}
export function DatePicker({
name,
id,
defaultValue,
value: controlledValue,
onValueChange,
placeholder = "Pick a date",
"aria-label": ariaLabel = "Select date",
disabled,
clearable = true,
className,
}: {
name?: string;
id?: string;
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
"aria-label"?: string;
disabled?: boolean;
clearable?: boolean;
className?: string;
}) {
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const [open, setOpen] = React.useState(false);
const value = controlledValue ?? internalValue;
const date = parseFormDate(value);
function update(next: Date | undefined) {
const nextValue = next ? format(next, "yyyy-MM-dd") : "";
if (controlledValue === undefined) setInternalValue(nextValue);
onValueChange?.(nextValue);
setOpen(false);
}
return (
<div className={cn("flex min-w-0 gap-1", className)}>
{name ? <input type="hidden" name={name} value={value} /> : null}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
id={id}
variant="outline"
disabled={disabled}
aria-label={ariaLabel}
data-empty={!date}
className="min-w-0 flex-1 justify-start px-3 text-left font-medium data-[empty=true]:text-muted-foreground"
>
<CalendarIcon data-icon="inline-start" />
<span className="truncate">
{date ? format(date, "PPP") : placeholder}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={update}
defaultMonth={date}
autoFocus
/>
</PopoverContent>
</Popover>
{clearable && value ? (
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
aria-label={`Clear ${ariaLabel.toLowerCase()}`}
onClick={() => update(undefined)}
>
<X data-icon="inline-start" />
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { format, isValid, parse } from "date-fns";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import { TimeFields, formatTimeValue } from "@/components/ui/time-picker";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function parseDate(value: string) {
const date = parse(value, "yyyy-MM-dd", new Date(2000, 0, 1));
return isValid(date) ? date : undefined;
}
// One popup with a draft: dismissing never modifies the controlled field.
export function DateTimePicker({id,value,onValueChange,disabled,ariaLabel="Date and time",className}:{
id:string;
value:string;
onValueChange:(value:string)=>void;
disabled?:boolean;
ariaLabel?:string;
className?:string;
}) {
const [open, setOpen] = useState(false);
const [draftDate, setDraftDate] = useState("");
const [draftTime, setDraftTime] = useState("19:00");
const selected = parseDate(value.slice(0,10));
const draft = parseDate(draftDate);
return <Popover open={open} onOpenChange={next => {
if (next) { setDraftDate(value.slice(0,10)); setDraftTime(value.slice(11,16)||"19:00"); }
setOpen(next);
}}>
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<PopoverTrigger asChild>
<Button id={id} type="button" variant="outline" disabled={disabled} aria-label={ariaLabel} className="min-w-0 flex-1 justify-between">
<CalendarIcon data-icon="inline-start" />
<span className="flex-1 truncate text-left">{selected ? `${format(selected,"PP")} · ${formatTimeValue(value.slice(11,16))}` : "Pick date & time"}</span>
<ChevronDown data-icon="inline-end" />
</Button>
</PopoverTrigger>
<Button type="button" variant="ghost" disabled={disabled || !value} aria-label={`Clear ${ariaLabel.toLowerCase()}`} onClick={() => { onValueChange(""); setOpen(false); }}>Clear</Button>
</div>
<PopoverContent align="start" collisionPadding={12} aria-label={`${ariaLabel}: choose date and time`} className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto p-3 max-h-[var(--radix-popover-content-available-height)]">
<Calendar mode="single" selected={draft} defaultMonth={draft} onSelect={date=>setDraftDate(date?format(date,"yyyy-MM-dd"):"")} className="self-center p-0" classNames={{ month: "flex w-[15.75rem] flex-col gap-1", week: "grid grid-cols-7" }} />
<Separator />
<TimeFields id={`${id}-time`} value={draftTime} onValueChange={setDraftTime} />
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Device timezone</p>
<Button type="button" disabled={!draft} onClick={()=>{onValueChange(`${draftDate}T${draftTime}`);setOpen(false);}}>Apply</Button>
</div>
</PopoverContent>
</Popover>;
}
+34
View File
@@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-card p-4 text-card-foreground shadow-xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,13 @@
import {expect,test} from "bun:test";
import {formatTimeValue,parseTimeValue,toTimeValue} from "./time-picker";
test("RaceTix time picker preserves midnight, noon and non-five-minute values",()=>{
expect(formatTimeValue("00:00")).toBe("12:00 AM");
expect(formatTimeValue("12:00")).toBe("12:00 PM");
expect(formatTimeValue("23:57")).toBe("11:57 PM");
for(let h=0;h<24;h++) for(const m of ["00","07","30","59"]) {
const value=`${String(h).padStart(2,"0")}:${m}`;
const parsed=parseTimeValue(value);
expect(toTimeValue(parsed.hour,parsed.minute,parsed.period)).toBe(value);
}
});
+130
View File
@@ -0,0 +1,130 @@
"use client";
import * as React from "react";
import { Clock3, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
type SelectFieldOption = {value:string;label:string};
function SelectField({id,ariaLabel,value,options,onValueChange}:{id:string;ariaLabel:string;value:string;options:SelectFieldOption[];onValueChange:(value:string)=>void}) {
return <Select value={value} onValueChange={onValueChange}><SelectTrigger id={id} aria-label={ariaLabel} className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{options.map(option=><SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup></SelectContent></Select>;
}
import { cn } from "@/lib/utils";
type Period = "AM" | "PM";
export function parseTimeValue(value: string) {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return { hour: "7", minute: "00", period: "PM" as Period };
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) {
return { hour: "7", minute: "00", period: "PM" as Period };
}
return {
hour: String(hours % 12 || 12),
minute: String(minutes).padStart(2, "0"),
period: (hours >= 12 ? "PM" : "AM") as Period,
};
}
export function toTimeValue(hour: string, minute: string, period: Period) {
const hourNumber = Number(hour) % 12;
const hours = period === "PM" ? hourNumber + 12 : hourNumber;
return `${String(hours).padStart(2, "0")}:${minute.padStart(2, "0")}`;
}
export function formatTimeValue(value: string) {
const { hour, minute, period } = parseTimeValue(value);
return `${hour}:${minute} ${period}`;
}
const hourOptions = Array.from({ length: 12 }, (_, index) => ({ value: String(index + 1), label: String(index + 1) }));
const standardMinutes = Array.from({ length: 12 }, (_, index) => String(index * 5).padStart(2, "0"));
export function TimeFields({ id, value, onValueChange }: {
id: string;
value: string;
onValueChange: (value: string) => void;
}) {
const { hour, minute, period } = parseTimeValue(value);
const minutes = standardMinutes.includes(minute) ? standardMinutes : [...standardMinutes, minute].sort();
return (
<FieldGroup className="flex-row gap-2">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-hour`}>Hour</FieldLabel>
<SelectField id={`${id}-hour`} ariaLabel="Hour" value={hour} options={hourOptions} onValueChange={next => onValueChange(toTimeValue(next, minute, period))} />
</Field>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-minute`}>Minute</FieldLabel>
<SelectField id={`${id}-minute`} ariaLabel="Minute" value={minute} options={minutes.map(value => ({ value, label: value }))} onValueChange={next => onValueChange(toTimeValue(hour, next, period))} />
</Field>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-period`}>Period</FieldLabel>
<SelectField id={`${id}-period`} ariaLabel="AM or PM" value={period} options={[{ value: "AM", label: "AM" }, { value: "PM", label: "PM" }]} onValueChange={next => onValueChange(toTimeValue(hour, minute, next as Period))} />
</Field>
</FieldGroup>
);
}
export function TimePicker({ id, name, value, defaultValue = "19:00", onValueChange, disabled, required, ariaLabel = "Time", className }: {
id?: string;
name?: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
required?: boolean;
ariaLabel?: string;
className?: string;
}) {
const generatedId = React.useId();
const pickerId = id ?? generatedId;
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(defaultValue);
const isControlled = value !== undefined;
const currentValue = value ?? internalValue;
return (
<Popover open={open} onOpenChange={setOpen}>
{name ? <input type="hidden" name={name} value={currentValue} /> : null}
<PopoverTrigger asChild>
<Button
type="button"
id={pickerId}
variant="outline"
disabled={disabled}
aria-label={ariaLabel}
aria-required={required}
className={cn(
"min-w-[8.75rem] justify-between gap-2 px-3 font-semibold",
className,
)}
>
<span className="flex items-center gap-2">
<Clock3 data-icon="inline-start" />
{formatTimeValue(currentValue)}
</span>
<ChevronDown data-icon="inline-start" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-4 p-4">
<div>
<p className="text-sm font-bold">Choose a time</p>
<p className="text-xs text-muted-foreground">Times use your devices timezone.</p>
</div>
<TimeFields id={pickerId} value={currentValue} onValueChange={next => { if (!isControlled) setInternalValue(next); onValueChange?.(next); }} />
<Button type="button" className="w-full" onClick={() => setOpen(false)}>
Done
</Button>
</PopoverContent>
</Popover>
);
}
@@ -16,11 +16,13 @@ import {
groupMemberships,
guests,
photos,
photoExports,
submissions,
user,
} from "@album/database";
import {
createEventInputSchema,
exportPhotosInputSchema,
checkEventSlugInputSchema,
generateEventSlugInputSchema,
locationSearchInputSchema,
@@ -79,6 +81,30 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id});
if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."});
await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}});
return job;
}),
exports: protectedProcedure.input(z.object({eventId:z.string().uuid()})).query(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports)
.where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10);
}),
downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id)));
if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."});
return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))};
}),
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
@@ -0,0 +1,39 @@
import {test,expect} from "bun:test";
import {and,eq,ne} from "drizzle-orm";
import {events,eventMemberships,getDb,groups,photoExports,user} from "@album/database";
import {managerRouter} from "./api/routers/manager";
import type {TrpcContext} from "./api/trpc";
test.skipIf(process.env.EXPORT_PERMISSIONS_INTEGRATION!=="1")("export authorization, requester scoping, revoked access and expiry",async()=>{
if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db=getDb();
const id=crypto.randomUUID();
const [person]=await db.insert(user).values({id,name:"Export test",email:`${id}@manyangles.test`,emailVerified:true}).returning();
const [group]=await db.select({id:groups.id}).from(groups).limit(1);
const [event]=await db.insert(events).values({groupId:group!.id,title:"Permission test",slug:`export-access-${id}`}).returning();
const ctx:TrpcContext={session:{user:person!,session:{}} as TrpcContext["session"],cookies:new Map(),activeGroupId:null,requestOrigin:"http://localhost:3000",clientIdentifier:id,guestTokenForEvent:()=>null,setCookies:[],appendSetCookie:()=>{}};
const caller=managerRouter.createCaller(ctx);
try {
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
await db.insert(eventMemberships).values({eventId:event!.id,userId:id,role:"viewer"});
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
const job=await caller.requestExport({eventId:event!.id,filter:"all"});
const scope=and(eq(photoExports.eventId,event!.id),eq(photoExports.id,job.id));
await db.update(photoExports).set({status:"ready"}).where(scope);
expect((await caller.downloadExport({eventId:event!.id,exportId:job.id})).url).toContain("X-Amz-Expires=300");
const [other]=await db.select({id:user.id}).from(user).where(ne(user.id,id)).limit(1);
await db.update(photoExports).set({requestedBy:other!.id}).where(scope);
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
await db.update(photoExports).set({requestedBy:id}).where(scope);
await expect(caller.downloadExport({eventId:crypto.randomUUID(),exportId:job.id})).rejects.toThrow();
await db.update(eventMemberships).set({role:"viewer"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
await db.update(photoExports).set({expiresAt:new Date(0)}).where(scope);
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow("expired");
} finally {
await db.delete(events).where(eq(events.id,event!.id));
await db.delete(user).where(eq(user.id,id));
}
});