diff --git a/backend/lib/db.ts b/backend/lib/db.ts index 7b78b42..94ae9f0 100644 --- a/backend/lib/db.ts +++ b/backend/lib/db.ts @@ -7,6 +7,7 @@ import OtpCode from "models/OtpCode.js"; import MagicLink from "models/MagicLink.js"; import RateLimit from "models/RateLimit.js"; import Log from "models/Log.js"; +import TripDocument from "models/TripDocument.js"; import mongoose from "mongoose"; let isConnected = false; @@ -48,4 +49,4 @@ export async function connect() { } } -export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log }; +export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log, TripDocument }; diff --git a/backend/lib/storage.ts b/backend/lib/storage.ts index f7eeee7..13731d4 100644 --- a/backend/lib/storage.ts +++ b/backend/lib/storage.ts @@ -1,4 +1,5 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { nanoId } from "lib/utils.js"; const { S3_KEY_ID, S3_SECRET, S3_ENDPOINT, S3_BUCKET, S3_PUBLIC_URL } = process.env; @@ -23,6 +24,13 @@ export function imageUrl(fileName?: string | null): string | null { return `${publicUrl}/${fileName}`; } +export async function createUploadUrl(key: string, contentType: string): Promise { + if (!client) return null; + return getSignedUrl(client, new PutObjectCommand({ Bucket: S3_BUCKET!, Key: key, ContentType: contentType }), { + expiresIn: 600, + }); +} + export async function deleteFromStorage(key: string): Promise { if (!client) return; await client.send(new DeleteObjectCommand({ Bucket: S3_BUCKET!, Key: key })); diff --git a/backend/models/TripDocument.ts b/backend/models/TripDocument.ts new file mode 100644 index 0000000..ab235ef --- /dev/null +++ b/backend/models/TripDocument.ts @@ -0,0 +1,26 @@ +import type { TripDocument } from "@birdplan/shared"; +import mongoose, { Schema, model, Model } from "mongoose"; +import { nanoId } from "lib/utils.js"; + +const fields: Record, any> = { + _id: { type: String, default: () => nanoId() }, + tripId: { type: String, required: true }, + name: { type: String, required: true }, + key: { type: String, required: true }, + size: { type: Number, required: true }, + mimeType: { type: String, required: true }, + category: { type: String, default: null }, + visibility: { type: String, default: "trip" }, + uploadedBy: { type: String, required: true }, +}; + +const TripDocumentSchema = new Schema(fields, { + timestamps: true, +}); + +TripDocumentSchema.index({ tripId: 1, createdAt: 1 }); + +const TripDocumentModel = + (mongoose.models.TripDocument as Model) || model("TripDocument", TripDocumentSchema); + +export default TripDocumentModel; diff --git a/backend/package.json b/backend/package.json index 6c54257..5fb4eba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", + "@aws-sdk/s3-request-presigner": "^3.1079.0", "@hono/node-server": "^1.14.4", "@maphubs/tokml": "^0.6.1", "axios": "^1.9.0", diff --git a/backend/routes/trips/[tripId]/documents.ts b/backend/routes/trips/[tripId]/documents.ts new file mode 100644 index 0000000..2b8a9a2 --- /dev/null +++ b/backend/routes/trips/[tripId]/documents.ts @@ -0,0 +1,172 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { authenticate, authenticateOptional, nanoId, sanitizeFileName } from "lib/utils.js"; +import { connect, Trip, TripDocument } from "lib/db.js"; +import { isTripEditor, isEditorInRoster, loadActiveRoster } from "lib/participants.js"; +import { createUploadUrl, deleteFromStorage, imageUrl } from "lib/storage.js"; +import type { + TripDocumentUploadUrlInput, + TripDocumentCreateInput, + TripDocumentUpdateInput, + TripDocumentCategory, + TripDocumentVisibility, +} from "@birdplan/shared"; + +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_DOCUMENTS_PER_TRIP = 50; + +const CATEGORIES: TripDocumentCategory[] = ["flights", "lodging", "transport", "permits", "maps", "other"]; +const VISIBILITIES: TripDocumentVisibility[] = ["private", "trip", "public"]; + +const documents = new Hono(); + +const requireEditor = async (c: any): Promise => { + const session = await authenticate(c); + const tripId = c.req.param("tripId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + + await connect(); + const [trip, isEditor] = await Promise.all([Trip.exists({ _id: tripId }), isTripEditor(tripId, session.userId)]); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); + + return session.userId; +}; + +const validateName = (name: unknown) => { + if (typeof name !== "string" || !name.trim() || name.length > 200) { + throw new HTTPException(400, { message: "Invalid file name" }); + } +}; + +const validateMeta = (name: unknown, size: unknown, mimeType: unknown) => { + validateName(name); + if (typeof size !== "number" || !Number.isFinite(size) || size <= 0) { + throw new HTTPException(400, { message: "Invalid file size" }); + } + if (size > MAX_DOCUMENT_BYTES) { + throw new HTTPException(400, { message: "Files can be up to 10 MB" }); + } + if (typeof mimeType !== "string" || !/^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) { + throw new HTTPException(400, { message: "Invalid file type" }); + } +}; + +const findVisibleDocument = async (tripId: string, documentId: string, userId: string) => { + const doc = await TripDocument.findOne({ _id: documentId, tripId }).lean(); + if (!doc || (doc.visibility === "private" && doc.uploadedBy !== userId)) { + throw new HTTPException(404, { message: "Document not found" }); + } + return doc; +}; + +documents.get("/", async (c) => { + const session = await authenticateOptional(c); + const tripId = c.req.param("tripId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + + await connect(); + const [trip, roster] = await Promise.all([Trip.findById(tripId).lean(), loadActiveRoster(tripId)]); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + + const isEditor = isEditorInRoster(roster, session?.userId); + if (!isEditor && !trip.isPublic) throw new HTTPException(403, { message: "Forbidden" }); + + const filter = isEditor + ? { tripId, $or: [{ visibility: { $ne: "private" } }, { uploadedBy: session?.userId }] } + : { tripId, visibility: "public" }; + + const docs = await TripDocument.find(filter).sort({ createdAt: 1 }).lean(); + return c.json(docs.map((doc) => ({ ...doc, url: imageUrl(doc.key) }))); +}); + +documents.post("/upload-url", async (c) => { + const tripId = c.req.param("tripId"); + await requireEditor(c); + + const data = await c.req.json(); + validateMeta(data.name, data.size, data.mimeType); + + const dot = data.name.lastIndexOf("."); + const base = dot > 0 ? data.name.slice(0, dot) : data.name; + const ext = dot > 0 ? data.name.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 10) : ""; + const safeBase = sanitizeFileName(base).replace(/ /g, "-").toLowerCase() || "file"; + const key = `docs/${tripId}/${nanoId()}/${safeBase}${ext ? `.${ext}` : ""}`; + + const uploadUrl = await createUploadUrl(key, data.mimeType); + if (!uploadUrl) throw new HTTPException(500, { message: "File storage is not configured" }); + + return c.json({ key, uploadUrl }); +}); + +documents.post("/", async (c) => { + const tripId = c.req.param("tripId"); + const userId = await requireEditor(c); + + const data = await c.req.json(); + validateMeta(data.name, data.size, data.mimeType); + if (typeof data.key !== "string" || !data.key.startsWith(`docs/${tripId}/`)) { + throw new HTTPException(400, { message: "Invalid document key" }); + } + + const count = await TripDocument.countDocuments({ tripId }); + if (count >= MAX_DOCUMENTS_PER_TRIP) { + throw new HTTPException(400, { message: `Trips can have up to ${MAX_DOCUMENTS_PER_TRIP} documents` }); + } + + const doc = await TripDocument.create({ + tripId, + name: data.name.trim(), + key: data.key, + size: data.size, + mimeType: data.mimeType, + uploadedBy: userId, + }); + + return c.json({ ...doc.toObject(), url: imageUrl(doc.key) }); +}); + +documents.patch("/:documentId", async (c) => { + const tripId = c.req.param("tripId"); + const documentId = c.req.param("documentId"); + const userId = await requireEditor(c); + + const data = await c.req.json(); + validateName(data.name); + const category = data.category || null; + if (category !== null && !CATEGORIES.includes(category)) { + throw new HTTPException(400, { message: "Invalid category" }); + } + if (!VISIBILITIES.includes(data.visibility)) { + throw new HTTPException(400, { message: "Invalid visibility" }); + } + + await findVisibleDocument(tripId!, documentId, userId); + const doc = await TripDocument.findOneAndUpdate( + { _id: documentId, tripId }, + { name: data.name.trim(), category, visibility: data.visibility }, + { new: true } + ).lean(); + if (!doc) throw new HTTPException(404, { message: "Document not found" }); + + return c.json({ ...doc, url: imageUrl(doc.key) }); +}); + +documents.delete("/:documentId", async (c) => { + const tripId = c.req.param("tripId"); + const documentId = c.req.param("documentId"); + const userId = await requireEditor(c); + + const doc = await findVisibleDocument(tripId!, documentId, userId); + + await TripDocument.deleteOne({ _id: documentId }); + try { + await deleteFromStorage(doc.key); + } catch (error) { + console.error("Failed to delete document from storage", doc.key, error); + } + + return c.json({}); +}); + +export default documents; diff --git a/backend/routes/trips/[tripId]/index.ts b/backend/routes/trips/[tripId]/index.ts index 0e3f49f..4389e9a 100644 --- a/backend/routes/trips/[tripId]/index.ts +++ b/backend/routes/trips/[tripId]/index.ts @@ -12,7 +12,7 @@ import { isDuplicateKeyError, validateTripDates, } from "lib/utils.js"; -import { connect, Trip, Participant, User, IntegrationToken } from "lib/db.js"; +import { connect, Trip, Participant, User, IntegrationToken, TripDocument } from "lib/db.js"; import { isTripEditor, isEditorInRoster, @@ -20,7 +20,7 @@ import { loadUsersById, resolveTripLifelist, } from "lib/participants.js"; -import { uploadMapboxImageToStorage, imageUrl } from "lib/storage.js"; +import { uploadMapboxImageToStorage, imageUrl, deleteFromStorage } from "lib/storage.js"; import { OPENBIRDING_API_URL, SHARE_CODE_TTL_MINUTES } from "lib/config.js"; import type { TripUpdateInput, TripDatesInput, OpenBirdingLocationResponse } from "@birdplan/shared"; import targetStars from "./targets.js"; @@ -28,6 +28,7 @@ import markers from "./markers.js"; import hotspots from "./hotspots.js"; import itinerary from "./itinerary.js"; import participants from "./participants.js"; +import documents from "./documents.js"; // @ts-ignore - no type definitions available import tokml from "@maphubs/tokml"; @@ -38,6 +39,7 @@ trip.route("/markers", markers); trip.route("/hotspots", hotspots); trip.route("/itinerary", itinerary); trip.route("/participants", participants); +trip.route("/documents", documents); trip.get("/", async (c) => { const session = await authenticateOptional(c); @@ -229,12 +231,23 @@ trip.delete("/", async (c) => { throw new HTTPException(403, { message: "Forbidden" }); } + const documents = await TripDocument.find({ tripId }).select("key").lean(); + await Promise.all([ Trip.deleteOne({ _id: tripId }), Participant.deleteMany({ tripId }), IntegrationToken.deleteMany({ tripId }), + TripDocument.deleteMany({ tripId }), ]); + await Promise.all( + documents.map((doc) => + deleteFromStorage(doc.key).catch((error) => + console.error("Failed to delete document from storage", doc.key, error) + ) + ) + ); + return c.json({}); }); diff --git a/frontend/components/Modal.tsx b/frontend/components/Modal.tsx index 640791a..fda0749 100644 --- a/frontend/components/Modal.tsx +++ b/frontend/components/Modal.tsx @@ -23,6 +23,7 @@ import ManageLifelist from "modals/ManageLifelist"; import GenerateMagicLink from "modals/GenerateMagicLink"; import Share from "modals/Share"; import TripNotes from "modals/TripNotes"; +import EditDocument from "modals/EditDocument"; type ModalConfig = { Component: React.ComponentType; @@ -45,6 +46,7 @@ const modals: Record = { generateMagicLink: { Component: GenerateMagicLink }, share: { Component: Share }, tripNotes: { Component: TripNotes }, + editDocument: { Component: EditDocument }, }; const ModalRoot = () => { diff --git a/frontend/components/TripDocuments.tsx b/frontend/components/TripDocuments.tsx new file mode 100644 index 0000000..57ec54c --- /dev/null +++ b/frontend/components/TripDocuments.tsx @@ -0,0 +1,177 @@ +import React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { TripDocument } from "@birdplan/shared"; +import { Card, CardContent, CardHeader, CardTitle, CardAction } from "components/ui/card"; +import { Button } from "components/ui/button"; +import { Spinner } from "components/ui/spinner"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/ui/dropdown-menu"; +import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; +import { mutate } from "lib/http"; +import { formatBytes, getDocumentCategory, getDocumentIcon, getDocumentVisibility } from "lib/documents"; +import { Upload, MoreHorizontal, PencilLine, Download, Trash2, FolderOpen } from "lucide-react"; + +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; + +export default function TripDocuments() { + const { trip, canEdit } = useTrip(); + const { open } = useModal(); + const queryClient = useQueryClient(); + const fileInputRef = React.useRef(null); + const [isUploading, setIsUploading] = React.useState(false); + + const queryKey = [`/trips/${trip?._id}/documents`]; + const { data: documents } = useQuery({ + queryKey, + enabled: !!trip, + }); + + if (!trip) return null; + if (!canEdit && !documents?.length) return null; + + const uploadFile = async (file: File) => { + if (file.size > MAX_DOCUMENT_BYTES) return toast.error("Files can be up to 10 MB"); + const mimeType = file.type || "application/octet-stream"; + setIsUploading(true); + try { + const { key, uploadUrl } = await mutate("POST", `/trips/${trip._id}/documents/upload-url`, { + name: file.name, + size: file.size, + mimeType, + }); + const res = await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": mimeType } }); + if (!res.ok) throw new Error("Upload failed. Please try again."); + await mutate("POST", `/trips/${trip._id}/documents`, { + key, + name: file.name, + size: file.size, + mimeType, + }); + queryClient.invalidateQueries({ queryKey }); + } catch (error: any) { + toast.error(error.message || "Upload failed. Please try again."); + } finally { + setIsUploading(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) uploadFile(file); + }; + + const handleDelete = async (doc: TripDocument) => { + if (!confirm(`Delete ${doc.name}?`)) return; + try { + await mutate("DELETE", `/trips/${trip._id}/documents/${doc._id}`); + queryClient.setQueryData(queryKey, (old) => old?.filter((it) => it._id !== doc._id)); + } catch (error: any) { + toast.error(error.message || "Failed to delete document"); + } + }; + + return ( + + + + Documents + {!!documents?.length && ( + {documents.length} + )} + + {canEdit && ( + + + + )} + + + + {documents?.length ? ( +
    + {documents.map((doc) => { + const DocIcon = getDocumentIcon(doc); + const category = getDocumentCategory(doc); + const visibility = getDocumentVisibility(doc.visibility); + return ( +
  • + + + +
    + + {doc.name} + +

    + + {category && <>{category.label} · } + {formatBytes(doc.size)} + + {canEdit && ( + + · + {visibility.label} + + )} +

    +
    + {canEdit && ( + + + } + > + + + + open("editDocument", { document: doc })}> + Edit details + + } + > + Download + + handleDelete(doc)}> + Delete + + + + )} +
  • + ); + })} +
+ ) : ( +
+ +

+ Flight itineraries, lodging confirmations, permits — keep the group's paperwork in one place. +

+
+ )} +
+
+ ); +} diff --git a/frontend/lib/documents.ts b/frontend/lib/documents.ts new file mode 100644 index 0000000..ea1187d --- /dev/null +++ b/frontend/lib/documents.ts @@ -0,0 +1,59 @@ +import { TripDocument, TripDocumentCategory, TripDocumentVisibility } from "@birdplan/shared"; +import { + BedDouble, + CarFront, + File, + FileImage, + FileSpreadsheet, + FileText, + Globe, + Lock, + Map, + Paperclip, + Plane, + Ticket, + Users, + type LucideIcon, +} from "lucide-react"; + +export const DOCUMENT_CATEGORIES: { value: TripDocumentCategory; label: string; icon: LucideIcon }[] = [ + { value: "flights", label: "Flights", icon: Plane }, + { value: "lodging", label: "Lodging", icon: BedDouble }, + { value: "transport", label: "Transport", icon: CarFront }, + { value: "permits", label: "Permits & tickets", icon: Ticket }, + { value: "maps", label: "Maps & guides", icon: Map }, + { value: "other", label: "Other", icon: Paperclip }, +]; + +export const DOCUMENT_VISIBILITIES: { + value: TripDocumentVisibility; + label: string; + description: string; + icon: LucideIcon; +}[] = [ + { value: "private", label: "Only me", description: "Hidden from everyone else on the trip", icon: Lock }, + { value: "trip", label: "Trip participants", description: "Everyone on this trip can see it", icon: Users }, + { value: "public", label: "Anyone with the link", description: "Visible to anyone who can view the trip", icon: Globe }, +]; + +const getMimeIcon = (mimeType: string): LucideIcon => { + if (mimeType.startsWith("image/")) return FileImage; + if (mimeType === "application/pdf" || mimeType.startsWith("text/")) return FileText; + if (/spreadsheet|excel|csv/.test(mimeType)) return FileSpreadsheet; + return File; +}; + +export const getDocumentCategory = (doc: Pick) => + DOCUMENT_CATEGORIES.find((it) => it.value === doc.category); + +export const getDocumentIcon = (doc: Pick): LucideIcon => + getDocumentCategory(doc)?.icon || getMimeIcon(doc.mimeType); + +export const getDocumentVisibility = (visibility?: TripDocumentVisibility) => + DOCUMENT_VISIBILITIES.find((it) => it.value === visibility) || DOCUMENT_VISIBILITIES[1]; + +export const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; diff --git a/frontend/modals/EditDocument.tsx b/frontend/modals/EditDocument.tsx new file mode 100644 index 0000000..071a12c --- /dev/null +++ b/frontend/modals/EditDocument.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { TripDocument, TripDocumentCategory, TripDocumentUpdateInput, TripDocumentVisibility } from "@birdplan/shared"; +import { Header, Body, Footer } from "components/Modal"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import Field from "components/Field"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "components/ui/select"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; +import useMutation from "hooks/useMutation"; +import { DOCUMENT_CATEGORIES, DOCUMENT_VISIBILITIES, getDocumentVisibility } from "lib/documents"; +import toast from "react-hot-toast"; + +type Props = { + document: TripDocument; +}; + +export default function EditDocument({ document: doc }: Props) { + const { close } = useModal(); + const { trip } = useTrip(); + const queryClient = useQueryClient(); + const [name, setName] = React.useState(doc.name); + const [category, setCategory] = React.useState(doc.category || "none"); + const [visibility, setVisibility] = React.useState(doc.visibility || "trip"); + + const mutation = useMutation({ + url: `/trips/${trip?._id}/documents/${doc._id}`, + method: "PATCH", + onSuccess: (updated) => { + queryClient.setQueryData([`/trips/${trip?._id}/documents`], (old) => + old?.map((it) => (it._id === updated._id ? updated : it)) + ); + close(); + }, + }); + + const handleSave = () => { + if (!name.trim()) return toast.error("Please enter a name"); + mutation.mutate({ name: name.trim(), category: category === "none" ? null : category, visibility }); + }; + + return ( + <> +
Edit document
+ + + setName(e.target.value)} autoFocus /> + + + + + + +

{getDocumentVisibility(visibility).description}

+
+ +
+
+ + +
+
+ + ); +} diff --git a/frontend/pages/[tripId]/index.tsx b/frontend/pages/[tripId]/index.tsx index 460660c..fc37a14 100644 --- a/frontend/pages/[tripId]/index.tsx +++ b/frontend/pages/[tripId]/index.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "components/ui/skeleton"; import Avatar from "components/Avatar"; import MarkerWithIcon from "components/MarkerWithIcon"; import { Tooltip, TooltipTrigger, TooltipContent } from "components/ui/tooltip"; +import TripDocuments from "components/TripDocuments"; import { useTrip } from "hooks/useTrip"; import { useModal } from "stores/modals"; import useDownloadTargets from "hooks/useDownloadTargets"; @@ -426,6 +427,8 @@ export default function TripOverview() { )} + + {canEdit && ( diff --git a/frontend/stores/modals.ts b/frontend/stores/modals.ts index be5bc38..efa873f 100644 --- a/frontend/stores/modals.ts +++ b/frontend/stores/modals.ts @@ -18,7 +18,8 @@ export type ModalId = | "manageLifelist" | "generateMagicLink" | "share" - | "tripNotes"; + | "tripNotes" + | "editDocument"; export const MODAL_POSITIONS: Record = { hotspot: "right", @@ -36,6 +37,7 @@ export const MODAL_POSITIONS: Record = { generateMagicLink: "center", share: "center", tripNotes: "center", + editDocument: "center", }; type ModalState = { diff --git a/package-lock.json b/package-lock.json index 588388c..8c5f61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "backend": { "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", + "@aws-sdk/s3-request-presigner": "^3.1079.0", "@hono/node-server": "^1.14.4", "@maphubs/tokml": "^0.6.1", "axios": "^1.9.0", @@ -888,6 +889,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1079.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1079.0.tgz", + "integrity": "sha512-NfHUaND7WyLUPkO7HCF3MFg4bdscY34A4tm4dPWa31qYzhGNZarRPr/CcRgllxzPoOSD/EHfZ4fQtZnMl2xWFg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.38", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", diff --git a/shared/types.ts b/shared/types.ts index c46b359..382b1ab 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -432,6 +432,44 @@ export type TripDatesInput = { endDate: string; }; +export type TripDocumentCategory = "flights" | "lodging" | "transport" | "permits" | "maps" | "other"; + +export type TripDocumentVisibility = "private" | "trip" | "public"; + +export type TripDocument = { + _id: string; + tripId: string; + name: string; + key: string; + size: number; + mimeType: string; + category?: TripDocumentCategory | null; + visibility: TripDocumentVisibility; + uploadedBy: string; + url?: string | null; + createdAt?: string; + updatedAt?: string; +}; + +export type TripDocumentUploadUrlInput = { + name: string; + size: number; + mimeType: string; +}; + +export type TripDocumentCreateInput = { + key: string; + name: string; + size: number; + mimeType: string; +}; + +export type TripDocumentUpdateInput = { + name: string; + category?: TripDocumentCategory | null; + visibility: TripDocumentVisibility; +}; + export type RemoveLocationInput = { id: string; };