feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f
This commit is contained in:
Amruth Pillai
2026-07-05 15:12:47 +02:00
parent 893df25aff
commit e99c1665d9
70 changed files with 32238 additions and 16668 deletions
+9
View File
@@ -24,6 +24,13 @@ const applicationSchema = createSelectSchema(schema.application, {
aiMetadata: aiMetadataSchema.nullable(),
campaign: z.string().trim().nullable(),
notes: z.string().nullable(),
// Rendered as an <a href>; only same-origin storage URLs are ever stored. Constrain to
// http(s)/relative so a hand-crafted `update` can't smuggle a `javascript:` href.
coverLetterUrl: z
.string()
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Cover letter URL must be http(s) or a relative path.")
.nullable(),
coverLetterName: z.string().nullable(),
followUpAt: z.date().nullable(),
followUpNote: z.string().trim().nullable(),
tags: z.array(z.string()),
@@ -47,6 +54,8 @@ const editableSchema = applicationSchema.pick({
jobDescription: true,
campaign: true,
notes: true,
coverLetterUrl: true,
coverLetterName: true,
followUpAt: true,
followUpNote: true,
contacts: true,
@@ -26,6 +26,8 @@ type EditableFields = {
jobDescription?: string | null | undefined;
campaign?: string | null | undefined;
notes?: string | null | undefined;
coverLetterUrl?: string | null | undefined;
coverLetterName?: string | null | undefined;
followUpAt?: Date | null | undefined;
followUpNote?: string | null | undefined;
contacts?: Contact[] | undefined;
+16 -5
View File
@@ -55,9 +55,21 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
function buildPictureKey(userId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
const EXTENSION_BY_CONTENT_TYPE: Record<string, string> = {
"image/jpeg": "jpeg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"application/pdf": "pdf",
};
// Derive the stored key's extension from the content type so the static handler serves each
// file correctly instead of mislabeling it. Images normally arrive as JPEG (sharp), but with
// FLAG_DISABLE_IMAGE_PROCESSING they keep their original type — hence all image types are
// mapped, not just JPEG. Non-image uploads (e.g. a cover-letter PDF) get their real extension.
function buildFileKey(userId: string, contentType: string): string {
const extension = EXTENSION_BY_CONTENT_TYPE[contentType] ?? "bin";
return `uploads/${userId}/pictures/${Date.now()}.${extension}`;
}
function buildPublicUrl(path: string): string {
@@ -337,9 +349,8 @@ interface UploadFileResult {
key: string;
}
// ponytail: only "picture" uploads exist in production; screenshot/pdf types were speculative dead code
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const key = buildPictureKey(input.userId);
const key = buildFileKey(input.userId, input.contentType);
await getStorageService().write({ key, data: input.data, contentType: input.contentType });
return { key, url: buildPublicUrl(key) };
}