mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-07 08:19:05 -05:00
* Restyle site chrome for v2: tokens, class-based theme toggle, Archivo, bottom tabs Move the color system to the v2 tokens in globals.css (cool neutrals, rose only for actions, green only for on-device ROM state) and alias the old variable names so existing components keep working. Dark mode is now a class on <html> set before first paint, with a sun/moon toggle in the header. Archivo replaces Geist and the Arial body fallback; headings use its width axis via font-display. Header drops the guest login and Beta pill, adds search and a quiet Submit link; phones get a three-tab bottom bar instead of a hamburger. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Rebuild HackCard for v2 and add a list row The card keeps the embla screenshot carousel, drag guard, and dots from the old card and reshuffles the text to the v2 layout: title, author with an outline completion pill, two-line summary, first two tags, then base ROM (or Ready), last update, and downloads. Screenshots sit in a well at native width on desktop and stretch on phones when the grid asks for it; pixelated rendering is now opt-in and only applied at whole-number scales. HackRow is the Discover list view. formatRelativeDate feeds the new updated field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Rebuild Discover for v2: filter rail, phone filter sheet, list view, pager above the grid Filtering, sorting, and URL state are unchanged in substance; the browser component now renders the v2 layout around them. Desktop gets a sticky filter rail with in-facet search, platform disclosures that collapse ROM revisions into one row, and catalog-wide counts. Phones get one Filters button that opens a 90% draft sheet with a live "Show N hacks" footer; close, scrim, Escape, and Back discard. Tags now OR within a category and AND across categories. Pages are 24 long with a small pager beside the result range and a full one below; grid and list views are remembered per device. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Rebuild Home for v2: promise hero, shelves from the discover cache, How it works last The page now reads the cached discover catalog instead of running its own queries, and slices it into Trending, New, and Recently updated shelves with hover-revealed paging arrows. A ready shelf leads once a base ROM is on the device, and the hero flips to "N hacks are one click away". The hero patch diagram and guest login link are gone; How it works keeps its copy and moves to the bottom for cold visitors, where the hero link jumps to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Rebuild the hack page for v2: patch module in the rail, compact header bar, tabs, lightbox filmstrip The sticky bottom bar is gone. The patch flow (HackActions and its state, onboarding, ROM checks, error modal) now renders as a patch module at the top of the rail: status line, version picker, one action button, facts, terms. When it scrolls behind the site header the title, picker, and the same button reappear in a compact bar portaled into the header, wrapping onto a second row on phones. The body is About / Gallery / Versions tabs; About keeps the screenshot stage, thumbnails, description, and latest changelog, Versions lists selectable patches and links to the full history. The lightbox keeps pixel-perfect scaling and scroll locking and gains a title bar and filmstrip. Tags collapse to one row with a peek and expand in place. Compatibility, box art, details, links, and more-from-author fill the rail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Start hacks as private server-side drafts with a checklist and Submit for review Submit now opens with a start card (title, base ROM, summary, own or on behalf) that creates a hack row with submitted_at null and sends the creator to the edit page. The edit page shows a status strip (Draft / In review / Listed) with a Preview-as-a-player link, a publishing checklist computed from the real row (patch, screenshots, description, tags, completion status), and Submit for review, which stamps submitted_at, opens the review thread, and tells the admins. Patch uploads on a draft no longer ping reviewers, the admin queue hides drafts, and the dashboard list shows Draft / In review / Approved. The old wizard stays for archive entries. Migration backfills submitted_at from created_at for everything that already exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Restyle My ROMs and the creator dashboard to match v2 Light touch only: page widths, display headings, the ready count as a green dot pill, ROM cards on the new tokens (green for on-device, amber for a permission prompt, orange-red for errors), tokenized buttons and stat cards. No layout changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Keep base ROM revisions visible in labels and Discover filters A hack is built against one dump, and revisions and regions hash differently, so the player picking a ROM needs the revision in front of them. The label helper now only drops the "Pokémon" prefix, and the Discover rail lists every base ROM on its own row instead of collapsing revisions into one game. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Nest ROM revisions under one game row in the Discover rail Games with several dumps (FireRed Rev 0 / Rev 1, regional releases) get a parent checkbox that selects or clears them all, an indeterminate dash when only some are on, and a chevron that reveals each dump with its own count. Single-dump games stay a plain row. Applied chips name the exact dump. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Put the revision chevron after the game name and open the list from the row The checkbox still selects every dump under a game; the rest of the row (name, chevron, count) is a button that expands or collapses the revisions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Give the Discover rail room between the counts and its scrollbar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Drop the updated date from hack cards Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Move the onboarding gate into the patch module and follow the action when scrolled The gate was styled for the old sticky bar and hung off the module as a stray tab. It is now a row under the primary action, a help icon beside the compact action on desktop, and a floating pill above the phone tab bar once the module scrolls away. The compact homes scroll back to the module before opening the tour. A rose beacon marks the icon while the player still needs a ROM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Switch to the compact bar when the action button hides and settle scroll before the tour opens Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Scroll the module into place from every help entry and hold user scrolling on the way Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Place the phone coach card under the patch module instead of above it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Edit drafts in place on the session page The session page becomes the hack page in edit mode for unlisted hacks: title, summary and tags in the header, a Write / Preview description in About, a screenshot manager in Gallery, and an Edit details sheet in the rail for language, completion, box art and links. Base ROM locks once a patch exists. Fields autosave; the status strip shows save state and a player preview via ?preview=1. The edit page redirects unlisted hacks here, and listed hacks keep the existing form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Edit listed hacks in place too, behind ?edit=1 The middleware now carries the query string through the session rewrite so ?edit=1 and ?preview=1 survive. Listed hacks open as players see them and switch to the editor from Edit in the options menu or the dashboard; the strip ends with Done editing. Only archive hacks still use the form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Hide the patch card and header actions while editing; checklist takes the patch slot Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Stage edits on listed hacks until Save; only drafts save live Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Open the details sheet from rail group headings, give the About editor its section, wrap the strip on phones Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Show the current version in the hack page header Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Fix the avatar-in-paragraph hydration error and re-apply the theme class after mount The byline held a block avatar inside a <p>, so the browser restructured the server HTML, hydration failed, and the client re-render reset <html> and dropped the dark class the head script had set. The byline is a <div> now, and ThemeToggle re-applies the stored or system theme on mount and follows system changes while no choice is stored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Polish pass: handle styling, card-wide ready outline, version on cards, rail height, sort width, How to play, footer login - Author handles render the at-sign in mono and muted so it reads as a handle. - Ready hacks outline the whole card (grid and list) instead of the screenshot. - Grid cards show the version next to the completion pill. - Desktop Discover rail fits the viewport from wherever its top sits, so it never needs both panes scrolled to reach the bottom. - Sort select fills the row on phones and has a stable id so the listbox button never hits a useId hydration mismatch. - Results area keeps a viewport of height so narrowing filters doesn't yank the page. - Version label centers against the picker pill in the patch module. - Tab strip overhang no longer creates a 1px vertical scroller; screenshot thumbnails keep room for the rose ring. - "Plays on" becomes a "How to play" rail group listing the FAQ's recommended emulators per platform. - Footer gains Log in (Dashboard once signed in, swapped after mount) and extra phone bottom padding for the floating help pill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Open pending hacks in edit mode only for their creator; keep the options menu for admins editing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let creators edit the creator name, permission source, and verification contact from the details sheet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restyle Select, Share, Report, and the auth forms to v2; share one dialog hook between Sheet and Modal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cards: mark archives, show when a linked ROM needs permission again, fall back to the description Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Desktop compact bar takes the search box's place so the nav stays visible Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Phone menu: a three-bar toggle that folds into an X and slides out Submit, account, FAQ, and Contact links from the right Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * My ROMs: show the unsupported-browser note only after mount to fix a hydration mismatch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Light v2 restyle of the remaining v1 pages: tokens, radii, status colors, primary buttons, and display-face titles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the page scroll while selects and option menus are open (no scroll lock, no scrollbar jump) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Label account links as creator-only: Become a creator / Creator log in, with a note that players need no account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Footer: Play / Create / Hackdex columns, legal links beside the copyright Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Desktop compact bar: fold nav, search, and Submit into a Menu dropdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Submit form: admin-only on-behalf option; creators confirm they made the hack, with a ToS note by the button Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Edit header: tags render as published with an Edit tags pill that opens the picker in a modal Byline moves under the title in edit mode to match the published order. TagSelector gets v2 tokens and a phone layout with category chips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tag picker: replace drag-to-reorder with a review view (card slots, Put on card, checkbox removal) The picker ends in a selected-count bar with Review. Review shows the two card tags with Make 1st/2nd and the rest with Put on card (asks which to replace) and undoable checkbox removal. Categories show pick counts and the picker opens on the first category. Escape inside a menu no longer closes the surrounding dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tag review: fixed height with only Also tagged scrolling; icon arrows on Back and Make 1st/2nd Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Submit for review: confirm modal with verification contact check, success screen, and While you wait card The modal shows the contact for a last look (editable) or asks for one, skippable, and submitForReview saves it in the same update. Success shows what happens next. In review, a While you wait card replaces the checklist. Also fixes submitForReview not revalidating the cached hack metadata, which left the page on Draft after submitting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Admin view of unsubmitted drafts: explanatory notice with progress and contact; block approving drafts Admins opening someone else's draft see an Unsubmitted draft notice with dates, required checklist progress, and the verification contact instead of the creator's private-draft copy. Approve and Create review thread are hidden for drafts, approveHack refuses them, and the approve page redirects. Admins in the editor can't submit on the creator's behalf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start form: editable page address with live availability check; taken addresses block create instead of getting a suffix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AI disclosure: per-hack levels for six areas, label in the rail, creator form in a modal, required checklist item Six nullable ai_level columns (content limited to none/some/most), an optional note, and ai_disclosed_at. The label is the round 10 design: headline (Contains AI / AI in code only / No direct AI usage), strip, and a collapsible breakdown. Hacks without one show a muted not-filled-in state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI disclosure: Discover filter (Any / No AI content / No direct AI usage) and a levels table in the FAQ Hacks without a disclosure still show under the strict options, since most use no AI and reports catch the rest. The URL keeps it as ?ai=no-content|none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI disclosure: new versions confirm the label (Still accurate) or update it before uploading Confirming re-stamps the label only once the upload lands. The first upload skips the step since the draft checklist already requires the label. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI disclosure: Needs AI label nudge on the dashboard, linking to the editor with the form open Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI disclosure: approve page warns when a hack has no AI label yet, without blocking Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI label: mouse-only strip hover so taps don't leave a cell looking selected; FAQ levels explained in one short paragraph instead of a table Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI label: link strip cells and breakdown rows only while the breakdown is open Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI form: intro copy matches the FAQ (vibes-based levels, intentional additions, one example of what doesn't count) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI form: on phones the save hint gets its own line and Cancel / Save split the width Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Security: move hack page reads out of the server actions file getHackMetadata and getHackDownloads lived in a "use server" file, so each was a public endpoint. Anyone could post a slug and get a private draft or pending hack, including the creator's email and verification contact, read with the service client. They now live in a plain server module that only the pages import, and those pages already gate what they render. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Security: keep upload keys inside the hack the caller can edit Signing trusted a client-supplied object key, so a creator could get an upload URL for another hack's screenshot or patch. Saving covers took any key too, and removing one later deleted that file from storage. - Screenshot keys must sit under `${slug}/` (new keys only; existing rows stay valid), and storage cleanup only touches that folder. - Patch keys are made on the server with a random suffix (new versions, reuploads, the archive form), and confirm steps check the key was made for this hack. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Security: patch download route applies the patcher's permission checks /api/patches/[id]/download signed any patch by id, so pending hacks, unpublished versions, and archived versions were downloadable anonymously. It now goes through getPatchDownloadUrl (published, not archived, the hack's download permission, and a parent hack the caller can see) and 404s otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Editing: keep the About and Gallery editors mounted across tab switches Switching tabs unmounted the editors, which dropped staged screenshot changes (and unregistered their save) and showed a stale description while the staged one still published. Their panels now stay mounted and are hidden instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Editing: upload staged screenshots before publishing anything on Save Save changes wrote the text fields first and uploaded screenshots after, so a failed upload left half the edit published. Committers now have a prepare step (uploads) that runs before any write. Edits made while a save is running also stay staged instead of being cleared. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Uploads: stop when the storage PUT fails, and check the file exists before finalizing A failed PUT resolved normally, so the version was still created and could be made current without a file behind it. Clients now check the response, and confirming a patch upload or reupload checks the object is in storage first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Uploads: refresh the hack page's cached data after finalizing a patch confirmPatchUpload only refreshed Discover, so a draft's first upload could keep showing no patch (or an old version) for up to four hours. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Dialogs: Shift+Tab right after opening stays inside the dialog Focus starts on the panel, and the trap only wrapped from its first control, so Shift+Tab escaped to the page behind. The trap now wraps from anywhere outside its controls and skips inert or hidden ones. The Discover filter sheet had a copy of the same logic and now uses useDialog. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Upload page: fit phones, and make the base ROM badge readable in light mode File inputs kept their intrinsic width, pushing the page to 410px at 360px. They now fill their container, and the base ROM upload's label sits above it. The badge used white text on a pale background in light mode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Editing: enforce title, summary, and screenshot limits on the server updateHack accepted any title or summary length, so a 110-character summary autosaved and listed hacks could publish one. It now rejects titles over 64 and summaries over 100, cover saves reject more than 10 screenshots, and drafts don't autosave a summary while it's over the limit (the counter is already red). The limits live in one shared module. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Versions: unique (hack, version) index so concurrent uploads can't duplicate one The app checked for an existing version before inserting, but two uploads finishing together could both pass. Postgres now enforces it, and the duplicate-key error reads the same as the existing check. (Distinct upload keys came with the storage key change.) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI label: fixed breakdown id instead of useId to clear a hydration warning useId produced different ids on the server and client for the rail label on a creator's draft, so aria-controls and id didn't match after hydration. The label takes a fixed id; the form's preview copy uses its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI form: open ?ai=1 after mount so the page doesn't 500 on the server The dashboard's Needs AI label link rendered the modal open during SSR, and Modal portals into document.body, which threw and returned a 500 before the client recovered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * A11y: 44px AI level buttons on phones; sheets don't slide with reduced motion On phones each level control spans the width with equal segments and 44px targets (they were 27px). The Discover filter sheet and the phone menu drawer skip their slide transition when reduced motion is on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Types: replace any in hack, cover, and patch write payloads with generated table types updateHack's payload, the cover upsert rows, and the patch insert were typed any, so schema mismatches passed the typecheck. They now use TablesUpdate and TablesInsert, row maps use inferred types, and the permission helpers take SupabaseClient<Database>. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Security: patch rows follow hack visibility and only the server creates them Patch rows were readable by anyone, so drafts' and pending hacks' file names leaked, and creators could insert a row pointing at another hack's file and then get it signed through their own hack. Reads now use the same rules as covers and tags, the client insert policy is gone (finalization inserts with the service client after its checks), a key can only be registered once, and patch keys use "__" after the slug so hack "foo" can't claim "foo-bar"'s uploads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Security: draft titles stay private in <title>; the archive wizard action needs archiver rights generateMetadata treated any signed-in visitor as allowed to see restricted titles, so a draft 404'd but its title showed in the tab. It now uses the same edit-permission check as the page. prepareSubmission (the archive wizard) only checked for a login, so anyone could call it to create an already-submitted hack without the checklist; it now requires is_archiver like its page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Drafts migration: backfill submitted_at without bumping updated_at The backfill fired set_hacks_updated_at, which would stamp every production hack with migration day, reordering dashboards and OpenGraph modified times. The trigger is disabled just for that update. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Editing: saves recover from network errors and never drop the last edit - run() catches thrown server actions, so a failed save shows an error instead of sticking on "Saving…" with Save disabled. - Listed hacks stage edits right away, so Save always includes the last one, and a draft edit still waiting on its debounce is committed when the editor unmounts (clicking Preview mid-typing lost it). - Gallery Save uploads and saves one snapshot, so a screenshot added while saving stays staged instead of being saved without its file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Caching: new drafts aren't stuck on a cached 404; notifications can't skip invalidation; drafts don't rebuild the catalog - createDraft invalidates the slug's metadata, so an address visited before it existed no longer 404s for its creator after creation. - Upload finalization and approval invalidate right after their writes, and their Discord/email notifications are best effort, so a failed webhook can't leave stale pages or report a saved upload as failed. Re-approving also refreshes metadata. - updateHack and saveHackCovers only rebuild the Discover catalog for listed hacks; draft autosaves were rebuilding it on every typing pause. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Drafts: need a playable patch to submit or approve; base ROM locks once any patch exists - A hack's first patch always becomes current (a draft's stays private), the checklist item is "A playable patch uploaded" (current_patch set), and approval refuses non-archive hacks without one. Before, an unpublished upload satisfied the checklist and could be approved with nothing to play. - The base ROM locks as soon as any version exists, and updateHack enforces it and checks the ROM id; it only locked once a patch was current, UI-only. - createDraft enforces the shared title and summary limits and a known base ROM. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * AI disclosure: server-enforced upload confirmation, UTC dates, and archives can be labeled - New versions pass the AI label stamp the uploader reviewed; confirmPatchUpload requires it to match the stored label and re-stamps it with the upload. The check was client-only and a failed re-stamp was ignored. confirmAiDisclosure is gone, and updateHack returns the stored stamp. - The label's date and the draft strip's submitted date render in UTC, so the server and visitors in other time zones agree (it broke hydration). - Archives get the AI label on their edit page through a shared AiLabelEditor (the in-place editor uses the same component), since the archive form had no way to fill it in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: the filter sheet's count includes the search, and committing leaves no extra Back step The results and the sheet's "Show N hacks" now share one predicate; the sheet skipped the search query, so it could promise 14 and show 4. Committing replaces the sheet's own history entry instead of pushing on top of it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Hack page: compact version picker selects; lightbox traps focus and closes on the backdrop; autoFocus survives dialogs - The module and compact-bar pickers shared one open flag, so the hidden one's outside-click handler closed the menu before an option registered. Each copy now opens on its own. - The lightbox uses useDialog (focus trap, Escape, focus back to the opener), and clicks outside the picture close it; the image's full-size wrapper used to swallow every click. - useDialog leaves focus on a field that autofocused inside the dialog (the submit modal's contact box) and still restores the real opener. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Polish: admin edit note matches how listed hacks save; named gallery handles; no shelf prefetch; seeded hacks submitted - Admins editing a listed hack were told changes save as they type; listed hacks publish on Save. - Gallery drag handles get an accessible name, staged previews' object URLs are released, and file extensions are cleaned so keys pass the server check. - Home shelves no longer prefetch every visible hack page (HackCard's default is off, as on Discover). - seed.sql marks seeded hacks as submitted, so local pending hacks aren't drafts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Lightbox: page scrolls again after closing Moving the lightbox onto useDialog left two effects saving and restoring <html>'s overflow. Cleanups run in declaration order, so useDialog restored it and then the lightbox put back the "hidden" it had captured, leaving the page unscrollable. useDialog now owns the <html> lock; the lightbox keeps only the scrollbar padding and touch blocking, measured before the lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Lightbox: the selected thumbnail's ring isn't clipped by the filmstrip The strip scrolls horizontally, which also clips vertically, so the 2px ring was cut off; it now has room with p-1. On phones the centering scroll also overshot: thumbs' offsetLeft was measured from the lightbox, not the strip, pushing the first thumb past the edge. The strip is now the offset parent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: AI filter radios fill when picked in the phone sheet The rail (hidden but mounted on phones) and the filter sheet rendered radios with the same name, so they formed one group across the page. Re-rendering the rail's checked option unchecked the sheet's pick, leaving no fill. Each filter instance now gets its own group name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: the phone Filters button stays screen-width with many filters applied The button and the applied-filter chips share a column that's a flex item, and flex items won't shrink below their content by default, so the unwrapped chip row stretched the column (and the button) past the screen. min-w-0 lets the column fit; the chip row already scrolls sideways. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * HackCard: Ready cards get a light green tint and faint green border instead of an outline The full-strength border plus ring clashed with the card and split into two lines with a gap on hover (a border and a box-shadow ring don't share a corner curve). Ready cards and list rows now tint the surface 4% green (5% in dark) with a border mixed 22% toward green, 40% on hover: one line, and calm when every card is Ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * HackCard: tag pills keep their contrast on the Ready tint The pills used a fixed surface-2, which sat too close to the tinted Ready card (especially in dark mode) and clashed with the green. They now shade whatever they're on with 8% of the text color, which matches surface-2 on a plain card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Discover: the filter rail's height is plain CSS, fixing a runaway scroll at the page bottom A scroll listener set the rail's max-height to the viewport minus its current top. At the end of the results the sticky rail gets pushed up, its top went negative, the rail grew, which pushed it further, so it raced to its bottom and snapped back when scrolling up. It's now max-h calc(100dvh - 84px) with no script; before the rail sticks, its bottom sits just below the fold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hack page: opening "How do I download?" no longer shifts the patch module The in-module entry row was removed while the tour was open, so everything below jumped up 42px, and back down on close. The row now stays in place, inactive and without its beacon, while the tour is open; the floating pill and compact-bar icon still go away since they don't affect layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Home: downloads milestone becomes an announcement card with room for a note The v1 pill (ring, shadow, gradient text) becomes a rose-tinted card above the hero headline: confetti disc, "1,000,000 downloads. Thank you!", and a short note about the v2 redesign. The whole card replays the confetti; hover deepens the tint and tilts the icon. No dismiss, no link. The number still comes from NEXT_PUBLIC_DOWNLOADS_MILESTONE (no trailing +), the note from a constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fresh-look banner on player pages; fix the before-paint theme script that never ran - A soft rose strip under the header on Discover, hack pages, FAQ, and My ROMs (not home, which has the milestone card, and not the editor): "Hackdex has a fresh new look. A thank you for 1,000,000 downloads..." The react-icons X is vertically centered at any wrap. Dismissal is stored per event, so the next milestone brings it back, and a before-paint script hides it for returning visitors without a flash. - The theme init script was exported from a "use client" module, so the root layout got a client reference instead of the string and it never ran (dark mode only applied after hydration). Both inline scripts now live in src/utils/initScripts.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Editor: confirm before in-app links drop unsaved work; failed autosaves offer Retry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Details sheet: Cancel discards the session's edits instead of keeping them for the next save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Version picker: keep the list on screen on phones, opening upward above the tab bar when needed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Discover filter sheet: focusing a hidden radio or checkbox no longer scrolls the sheet's frame Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Phone menu and filter sheet close when the window grows to desktop, releasing their scroll lock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start form: a network failure shows a retryable error instead of sticking on "Creating…" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lightbox: the thumbnail strip swipes on touch again; the page behind still stays put Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hack page tabs and screenshot strips: arrow, Home, and End keys with a roving tabIndex; inset focus ring Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screenshot manager: reorder with Space and the arrow keys Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Version management: readable text and real hovers on status buttons, legible Re-upload badges, file inputs fit on phones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Account and version menus: surface panels so the highlighted item stands out Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Touch: 40px hit areas for compact editor controls and the version chip; the Details sheet respects reduced motion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Copy: the trending shelf no longer claims a seven-day window; signup asks people to invite a hack's creator instead of sharing it for them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screenshot manager: an add builds on the latest list and saves run in order, so a delete or reorder during an upload sticks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Header search updates Discover in place when it's already open Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Editor strip: a later successful save no longer hides an earlier failure and its Retry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drafts: warn before reloading while an autosave is waiting or in flight Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Submit and AI dialogs recover from thrown actions; a failed Discord fallback no longer fails a submission that already landed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Home: linked hero names the ROMs in its paragraph instead of a pill; milestone card becomes a full-width strip above the hero Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * My ROMs shows the number of ready base ROMs instead of a dot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Patch button keeps keyboard focus through patching It was an inner component, so every status tick remounted it, and busy states used disabled, which blurs a focused button. It's now rendered by a function, and busy and just-patched states use aria-disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Archivers submit someone else's hack through drafts; the archive wizard is gone The wizard never set is_archive, so it made ordinary hacks that skipped the draft checklist and AI label. Archivers (admins included) now get the on-behalf option on the start form. Archives skip the AI re-check on new versions and the dashboard's Needs AI label badge, since the label is optional there, and AI save errors on the patch page show as a toast instead of behind the modal. The start form also asks for a page address when the title has no ASCII letters or digits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Editor: gallery hydrates cleanly and clears Unsaved on save; archive form matches server limits and cleans cover names Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Discover: unchanged filter sheet leaves no extra history entry, copy-link shows on phones, counts format as en-US Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Toasts and Turnstile follow the site theme; phone menu clears the notice banner; focus opens collapsed tags; only the top dialog handles Escape and Tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Announcement lock records its PID so a killed run's lock clears Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Discover: AI filter by area, with presets that tick their areas and an Allow small usage step for code The preset radios stay. Choose areas opens a checklist that mirrors the pick; a set matching no preset shows as Custom. Ticking Code yourself allows small use (a bug fix or a few) by default, while the presets stay strict. Custom picks go in the URL as areas joined by ".", and the catalog now carries each hack's six AI levels. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: the AI filter is a saved preference instead of a URL param It's stored on the device and read before the results show, so it applies on every visit and never travels in a shared link. Changing it anywhere saves it, and Any clears it. The AI use group notes that its settings are saved across sessions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: indent the AI area checklist under Choose areas Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Hack credits, submission, downloads, current patch and AI label are server-only in the database The owner policies let a creator write any column straight through PostgREST: mark a draft submitted without its checklist, credit someone else, set downloads, or repoint current_patch. hacks_update_guard now covers inserts too and blocks those columns for everyone but admins, the service role and postgres. The app writes them with the service client after its own permission checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * A first patch upload returns to the hack page, not Manage Versions The first upload always goes live, but the redirect only looked at the publish checkbox, so a draft's first upload landed away from its checklist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Milestone confetti draws under the phone menu Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Remove CSS left from the v1 hero, wizard and buttons Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: the saved AI filter shows on the results count line instead of as a chip, and Clear leaves it The count line reads "· No AI content" (or "Some AI usage hidden" for a custom pick) and opens the filter sheet at AI use on phones, or flashes it in the rail on desktop. Clear all, Clear N and Clear filters now reset only the search's filters; the phone Filters badge still counts the AI filter. The empty state offers Change AI filter when one is on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Discover: on phones the copy-link button sits beside Filters Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Hack page: Share and the options menu sit on the right on phones Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Hack page: the About tab's screenshot swipes again The v2 stage was a static image, which dropped main's Embla carousel. It's a looping carousel again (swipe or drag), kept in sync with the thumbnails, arrow keys and the lightbox; a drag never opens the lightbox. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Start form: title placeholder reads "My Pokémon Romhack" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Start form: "Page URL" instead of "Page address", and no autofill on its fields Phones read "address" as a street address and offered the visitor's home address for every field below it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Home shelves: cards are 266px so screenshots sit at exactly 1× Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Combine the branch's five migrations into one v2_redesign migration None of them have run in production, and db push now applies them in one go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cards: phone screenshots stay at 1× with dimmed neighbors over a blurred backdrop; bars under the shot On phones the grid stretched shots to the card width (1.13–1.42×), so the pixel art was smoothed. Now each shot stays at 1×, the neighbors peek in at 40%, and a blurred copy of the current shot fills the card and crossfades as you swipe. The dots were invisible over white text boxes, so they're bars under the shot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start form: creator name placeholder reads "Their name or handle" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cards: dragging the screenshots no longer selects text in Firefox Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fresh-look banner: the confetti icon centers vertically in the banner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fresh-look banner: only shows while NEXT_PUBLIC_DOWNLOADS_MILESTONE is 1000000 Its copy thanks players for 1,000,000 downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * FAQ: disclose that a large portion of Hackdex's code was written with AI assistance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AI label: content areas offer None / Some / All instead of Most Content areas now top out at All (hint: "Most or all of …"). Code keeps its five-level scale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CI: pin the Supabase CLI to 2.111.0 so the generated-types check is stable CLI 2.119.0 (what "latest" resolves to since Sep 30) writes generated types unformatted, so the check failed for every PR touching supabase/. 2.111.0 reproduces the checked-in src/types/db.ts exactly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
340 lines
19 KiB
TypeScript
340 lines
19 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test, { type TestContext } from "node:test";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { Resend } from "resend";
|
|
import { renderEmail } from "../src/emails/render.ts";
|
|
import type { Database } from "../src/types/db.ts";
|
|
import { collectRecipients, loadAnnouncement, main, sendAnnouncement } from "./send-announcement.mts";
|
|
|
|
const announcement = { id: "test-notice", title: "Creator notice", message: "Please review your credits.", ready: true };
|
|
const message = { from: "Hackdex <notice@example.com>", replyTo: "test@example.com", subject: announcement.title, html: "<p>Please review your credits.</p>", text: announcement.message };
|
|
const recipients = [{ email: "creator@example.com", userIds: ["00000000-0000-4000-8000-000000000001"], hacks: ["hack-one"] }];
|
|
|
|
async function temporaryDirectory(t: TestContext) {
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "hackdex-announcement-"));
|
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
return directory;
|
|
}
|
|
|
|
test("announcement escapes content, preserves paragraphs, and links URLs", async () => {
|
|
const html = await renderEmail("announcement", {
|
|
title: '<img src=x onerror="alert(1)"> & news',
|
|
message: 'Hello <script>alert(1)</script>\n\nRead https://www.hackdex.app/terms?a=1&b=2.\njavascript:alert(1)',
|
|
});
|
|
assert.ok(html.includes("<img"));
|
|
assert.ok(html.includes("<script>"));
|
|
assert.ok(html.includes("<br /><br />"));
|
|
assert.ok(html.includes('href="https://www.hackdex.app/terms?a=1&b=2"'));
|
|
assert.ok(html.includes("You can reply to this email"));
|
|
assert.ok(!html.includes("Please do not reply"));
|
|
assert.ok(!html.includes("<script>"));
|
|
assert.ok(!html.includes('href="javascript:'));
|
|
});
|
|
|
|
test("announcement renders section and question headings", async () => {
|
|
const html = await renderEmail("announcement", {
|
|
title: "Feature announcement",
|
|
message: "Intro.\n\n## How it works\n\nFirst paragraph.\n\nSecond paragraph.\r\n\r\n### A question?\r\n\r\nAn answer.",
|
|
});
|
|
assert.match(html, /<h2\b[^>]*font-size:20px[^>]*>How it works<\/h2>/);
|
|
assert.match(html, /<h3\b[^>]*font-weight:700[^>]*>A question\?<\/h3>/);
|
|
assert.ok(html.includes("First paragraph.<br /><br />Second paragraph."));
|
|
assert.doesNotMatch(html, /<br \/><h[23]|<\/h[23]><br \/>/);
|
|
assert.ok(!html.includes("## "));
|
|
});
|
|
|
|
test("announcement heading text stays escaped", async () => {
|
|
const html = await renderEmail("announcement", {
|
|
title: "Feature announcement",
|
|
message: '## <img src=x onerror="alert(1)"> & news\n\nRead https://www.hackdex.app/faq.',
|
|
});
|
|
assert.match(html, /<h2\b[^>]*><img src=x onerror="alert\(1\)"> & news<\/h2>/);
|
|
assert.ok(!html.includes("<img src=x"));
|
|
assert.ok(html.includes('href="https://www.hackdex.app/faq"'));
|
|
});
|
|
|
|
test("announcement screenshots link to their full-size images", async () => {
|
|
const html = await renderEmail("announcement", {
|
|
title: "Feature announcement",
|
|
message: '## Preview\n\n\n\n\n\nRead https://www.hackdex.app/faq.',
|
|
});
|
|
const screenshots = [...html.matchAll(/<a\b[^>]*href="(https:\/\/example\.com\/[^\"]+)"[^>]*>\s*(<img\b[^>]*>)\s*<\/a>/g)];
|
|
assert.equal(screenshots.length, 2);
|
|
assert.equal(screenshots[0][1], "https://example.com/form.png?v=1&size=large");
|
|
assert.match(screenshots[0][2], /alt="Form & player preview"/);
|
|
assert.match(screenshots[1][2], /alt="Code disclosure"/);
|
|
for (const [, url, img] of screenshots) {
|
|
assert.ok(img.includes(`src="${url}"`));
|
|
assert.match(img, /width:100%/);
|
|
assert.match(img, /height:auto/);
|
|
}
|
|
assert.ok(html.includes('href="https://www.hackdex.app/faq"'));
|
|
assert.ok(!html.includes("',
|
|
});
|
|
const image = html.match(/<img\b[^>]*src="https:\/\/example\.com\/form.png"[^>]*>/)?.[0];
|
|
assert.ok(image);
|
|
assert.ok(image.includes('alt="Preview " onerror="alert(1) <script>"'));
|
|
assert.ok(!image.includes(' onerror="'));
|
|
assert.ok(!html.includes("<script>"));
|
|
});
|
|
|
|
test("announcement images reject non-HTTP sources", async () => {
|
|
const html = await renderEmail("announcement", {
|
|
title: "Feature announcement",
|
|
message: ')\n\n\n\n\n\n<img src="https://example.com/raw.png">',
|
|
});
|
|
assert.ok(!html.includes('src="javascript:'));
|
|
assert.ok(!html.includes('src="file:'));
|
|
assert.ok(!html.includes('src="data:'));
|
|
assert.ok(!html.includes('src="https://example.com/raw.png"'));
|
|
assert.ok(html.includes("![Unsafe]"));
|
|
assert.ok(html.includes("<img"));
|
|
});
|
|
|
|
test("announcement files validate the campaign ID and required copy", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const file = path.join(directory, "notice.json");
|
|
await writeFile(file, JSON.stringify(announcement));
|
|
assert.deepEqual(await loadAnnouncement(file), announcement);
|
|
for (const invalid of [{ ...announcement, id: "../escape" }, { ...announcement, message: " " }, { ...announcement, title: "Subject\nBcc: someone" }]) {
|
|
await writeFile(file, JSON.stringify(invalid));
|
|
await assert.rejects(loadAnnouncement(file), /Announcement needs/);
|
|
}
|
|
});
|
|
|
|
test("announcement loads Markdown relative to its JSON file", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const file = path.join(directory, "notice.json");
|
|
const { message, ...metadata } = announcement;
|
|
const markdown = `## Creator notice\n\n${message}\n\n\n`;
|
|
await mkdir(path.join(directory, "copy"));
|
|
await writeFile(path.join(directory, "copy", "notice.md"), markdown);
|
|
await writeFile(file, JSON.stringify({ ...metadata, messageFile: "copy/notice.md" }));
|
|
assert.deepEqual(await loadAnnouncement(file), { ...announcement, message: markdown });
|
|
});
|
|
|
|
test("announcement rejects ambiguous or invalid message sources", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const file = path.join(directory, "notice.json");
|
|
const { message, ...metadata } = announcement;
|
|
for (const source of [
|
|
{}, { message, messageFile: "notice.md" }, { messageFile: " " },
|
|
{ messageFile: null }, { messageFile: 123 }, { message: null },
|
|
]) {
|
|
await writeFile(file, JSON.stringify({ ...metadata, ...source }));
|
|
await assert.rejects(loadAnnouncement(file), /Announcement needs/);
|
|
}
|
|
});
|
|
|
|
test("announcement reports missing or empty Markdown files", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const file = path.join(directory, "notice.json");
|
|
const { message: _message, ...metadata } = announcement;
|
|
await writeFile(file, JSON.stringify({ ...metadata, messageFile: "notice.md" }));
|
|
await assert.rejects(loadAnnouncement(file), { code: "ENOENT" });
|
|
await writeFile(path.join(directory, "notice.md"), " \n\t");
|
|
await assert.rejects(loadAnnouncement(file), /Announcement needs/);
|
|
});
|
|
|
|
test("recipient selection paginates, excludes empty drafts, and deduplicates creator emails", async () => {
|
|
const offsets: number[] = [];
|
|
const authLookups: string[] = [];
|
|
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
global: { fetch: async (input, init) => {
|
|
const url = new URL(new Request(input, init).url);
|
|
if (url.pathname === "/rest/v1/hacks") {
|
|
assert.equal(url.searchParams.get("is_archive"), "eq.false");
|
|
const columns = new Set(["slug", "created_by", "current_patch", "patch_url", "approved"]);
|
|
const unknown = url.searchParams.get("select")?.split(",").find((column) => !columns.has(column));
|
|
if (unknown) return Response.json({ message: `column hacks.${unknown} does not exist`, code: "42703" }, { status: 400 });
|
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
offsets.push(offset);
|
|
const row = { created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false };
|
|
return Response.json(offset === 0
|
|
? Array.from({ length: 1000 }, (_, i) => ({ ...row, slug: `hack-${i}` }))
|
|
: [
|
|
{ ...row, slug: "pending", created_by: "00000000-0000-4000-8000-000000000002", current_patch: 2 },
|
|
{ ...row, slug: "legacy", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, patch_url: "patch.bps" },
|
|
{ ...row, slug: "approved", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, approved: true },
|
|
{ ...row, slug: "empty-draft", created_by: "draft-owner", current_patch: null },
|
|
]);
|
|
}
|
|
const id = url.pathname.split("/").at(-1)!;
|
|
authLookups.push(id);
|
|
return Response.json({ id, email: id === "00000000-0000-4000-8000-000000000003" ? "second@example.com" : "Creator@Example.com", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
|
} },
|
|
});
|
|
const result = await collectRecipients(supabase);
|
|
assert.deepEqual(offsets, [0, 1000]);
|
|
assert.deepEqual(authLookups, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002", "00000000-0000-4000-8000-000000000003"]);
|
|
assert.equal(result.length, 2);
|
|
assert.equal(result[0].email, "creator@example.com");
|
|
assert.deepEqual(result[0].userIds, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"]);
|
|
assert.equal(result[0].hacks.length, 1001);
|
|
assert.deepEqual(result[1].hacks, ["legacy", "approved"]);
|
|
});
|
|
|
|
test("recipient preparation reports database failures instead of returning an empty audience", async () => {
|
|
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
|
auth: { persistSession: false },
|
|
global: { fetch: async () => Response.json({ message: "Permission denied" }, { status: 403 }) },
|
|
});
|
|
await assert.rejects(collectRecipients(supabase), /Cannot load hacks: Permission denied/);
|
|
});
|
|
|
|
test("recipient preparation stops when an uploaded hack's creator has no email", async () => {
|
|
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
|
auth: { persistSession: false },
|
|
global: { fetch: async (input, init) => {
|
|
const url = new URL(new Request(input, init).url);
|
|
return Response.json(url.pathname === "/rest/v1/hacks"
|
|
? [{ slug: "uploaded", created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false }]
|
|
: { id: "00000000-0000-4000-8000-000000000001", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
|
} },
|
|
});
|
|
await assert.rejects(collectRecipients(supabase), /has no valid account email/);
|
|
});
|
|
|
|
test("conflicting modes are rejected before any network request", async (t) => {
|
|
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
|
await assert.rejects(main(["notice.json", "--test", "preview@example.com", "--send"]), /at most one mode/);
|
|
});
|
|
|
|
test("default mode renders offline without querying recipients or sending email", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const previousCwd = process.cwd();
|
|
process.chdir(directory);
|
|
t.after(() => process.chdir(previousCwd));
|
|
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
|
const { message: _message, ...metadata } = announcement;
|
|
await writeFile("notice.md", "## Details\n\nRead https://www.hackdex.app/faq.");
|
|
await writeFile("notice.json", JSON.stringify({ ...metadata, messageFile: "notice.md", ready: false }));
|
|
await main(["notice.json"]);
|
|
const html = await readFile(".local/announcements/test-notice/preview.html", "utf8");
|
|
assert.ok(html.includes("Creator notice"));
|
|
assert.match(html, /<h2\b[^>]*>Details<\/h2>/);
|
|
assert.ok((await readFile(".local/announcements/test-notice/preview.txt", "utf8")).includes("## Details\n\nRead https://www.hackdex.app/faq."));
|
|
assert.deepEqual((await readdir(".local/announcements/test-notice")).sort(), ["preview.html", "preview.txt"]);
|
|
});
|
|
|
|
test("test mode sends only to its explicit address and never uses the creator snapshot", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const previousCwd = process.cwd();
|
|
process.chdir(directory);
|
|
t.after(() => process.chdir(previousCwd));
|
|
const env = { RESEND_FROM: message.from, RESEND_REPLY_TO: message.replyTo, RESEND_API_KEY: "re_test_key" };
|
|
for (const [key, value] of Object.entries(env)) {
|
|
const old = process.env[key];
|
|
process.env[key] = value;
|
|
t.after(() => { if (old === undefined) delete process.env[key]; else process.env[key] = old; });
|
|
}
|
|
let sent = 0;
|
|
t.mock.method(globalThis, "fetch", async (input: string, init: RequestInit) => {
|
|
assert.equal(input, "https://api.resend.com/emails");
|
|
const body = JSON.parse(String(init.body));
|
|
assert.equal(body.to, "preview@example.com");
|
|
assert.equal(body.reply_to, "test@example.com");
|
|
assert.equal(body.subject, "[TEST] Creator notice");
|
|
assert.equal(body.cc, undefined);
|
|
assert.equal(body.bcc, undefined);
|
|
sent++;
|
|
return Response.json({ id: "test-email" });
|
|
});
|
|
await writeFile("notice.json", JSON.stringify({ ...announcement, ready: false }));
|
|
await mkdir(".local/announcements/test-notice", { recursive: true });
|
|
await writeFile(".local/announcements/test-notice/recipients.json", "intentionally invalid snapshot");
|
|
await main(["notice.json", "--test", "preview@example.com"]);
|
|
assert.equal(sent, 1);
|
|
assert.equal(await readFile(".local/announcements/test-notice/recipients.json", "utf8"), "intentionally invalid snapshot");
|
|
assert.ok(!(await readdir(".local/announcements/test-notice")).includes("payload.json"));
|
|
});
|
|
|
|
test("production sending rejects drafts and unresolved placeholders", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const mailer = new Resend("re_test_key").emails;
|
|
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected email"); });
|
|
for (const draft of [{ ...announcement, ready: false }, { ...announcement, message: "Effective {{effectiveDate}}" }]) {
|
|
await assert.rejects(sendAnnouncement({ announcement: draft, message, recipients, directory, mailer }), /Announcement is a draft/);
|
|
}
|
|
assert.deepEqual(await readdir(directory), []);
|
|
});
|
|
|
|
test("production sending rejects placeholders loaded from Markdown", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const file = path.join(directory, "notice.json");
|
|
const { message: _message, ...metadata } = announcement;
|
|
await writeFile(path.join(directory, "notice.md"), "Effective {{effectiveDate}}");
|
|
await writeFile(file, JSON.stringify({ ...metadata, messageFile: "notice.md" }));
|
|
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected email"); });
|
|
await assert.rejects(sendAnnouncement({
|
|
announcement: await loadAnnouncement(file), message, recipients,
|
|
directory: path.join(directory, "delivery"), mailer: new Resend("re_test_key").emails,
|
|
}), /Announcement is a draft/);
|
|
assert.deepEqual((await readdir(directory)).sort(), ["notice.json", "notice.md"]);
|
|
});
|
|
|
|
test("accepted sends persist IDs and are skipped on subsequent runs", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
const requests: string[] = [];
|
|
t.mock.method(globalThis, "fetch", async (_input: string, init: RequestInit) => {
|
|
requests.push(new Headers(init.headers).get("Idempotency-Key")!);
|
|
assert.equal(JSON.parse(String(init.body)).to, "creator@example.com");
|
|
return Response.json({ id: "accepted-email" });
|
|
});
|
|
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
|
assert.deepEqual(await sendAnnouncement(options), { accepted: 1, skipped: 0 });
|
|
assert.deepEqual(await sendAnnouncement(options), { accepted: 0, skipped: 1 });
|
|
assert.equal(requests.length, 1);
|
|
assert.match(requests[0], /^announcement\/test-notice\/[a-f0-9]{64}$/);
|
|
const recordName = (await readdir(directory)).find((file) => file.endsWith(".sent.json"))!;
|
|
const record = JSON.parse(await readFile(path.join(directory, recordName), "utf8"));
|
|
assert.equal(record.emailId, "accepted-email");
|
|
assert.equal(record.email, "creator@example.com");
|
|
await assert.rejects(sendAnnouncement({ ...options, message: { ...message, subject: "Changed" } }), /Message or recipients changed/);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test("uncertain delivery stops the run and blocks blind resends", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
let requests = 0;
|
|
t.mock.method(globalThis, "fetch", async () => { requests++; throw new Error("Connection lost"); });
|
|
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
|
await assert.rejects(sendAnnouncement(options), /Resend did not confirm/);
|
|
await assert.rejects(sendAnnouncement(options), /Unresolved delivery/);
|
|
assert.equal(requests, 1);
|
|
const files = await readdir(directory);
|
|
assert.equal(files.filter((file) => file.endsWith(".pending.json")).length, 1);
|
|
assert.equal(files.filter((file) => file.endsWith(".sent.json")).length, 0);
|
|
assert.ok(!files.includes("send.lock"));
|
|
});
|
|
|
|
test("concurrent sending cannot bypass the campaign lock", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
await writeFile(path.join(directory, "send.lock"), "another process owns this");
|
|
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /another send is running/);
|
|
assert.equal(await readFile(path.join(directory, "send.lock"), "utf8"), "another process owns this");
|
|
// A live owner's PID blocks too.
|
|
await writeFile(path.join(directory, "send.lock"), String(process.pid));
|
|
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /another send is running/);
|
|
});
|
|
|
|
test("a lock left by a killed run is cleared", async (t) => {
|
|
const directory = await temporaryDirectory(t);
|
|
// Above Linux's PID ceiling, so no process has it.
|
|
await writeFile(path.join(directory, "send.lock"), "99999999");
|
|
t.mock.method(globalThis, "fetch", async () => Response.json({ id: "accepted-email" }));
|
|
assert.deepEqual(await sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 }), { accepted: 1, skipped: 0 });
|
|
assert.ok(!(await readdir(directory)).includes("send.lock"));
|
|
});
|