Author: Christian Denzon
Reviewer: Patrick Babala, Clyde Ador
Creation Date: September 30, 2026
Last Updated: September 30, 2026
Status: Approved and Merged
References:
Issue #727 — [3.6.1] Bulk Select/Delete/Archive/Restore Quests, Adventures, Background Assets
Parent epic: Issue #37 — [3.6] Archive Asset
Update PRs: #728 Bulk Selection (core), #711 Archive Asset, #725 Cron Auth Fix, #726 Deletion-Warning Email Link, #770 Archive Auto-Purge & Sorting
Related, documented separately: #745 MFPS Core Logic (bulk Move on the shared action bar, folder-aware authorization), #780 Reusable Confirm-Destructive Modal (issue #777)
INTRODUCTION AND GOALS
Problem Summary: This feature is the creator-side content clean-up toolkit for the three main content types on WyzQuests: quests, adventures, and background assets. Before it, deleting or archiving required clicking each item's own trash button, and every delete API accepted exactly one resource ID per request. Bulk Actions (issue #727, delivered by PR #728) adds hover-revealed checkboxes and a floating contextual action bar so a creator can select many items at once and Archive, Restore, or Delete (permanently) them in one flow.
The lifecycle under the selection layer was established by the Archive Asset work (PR #711) and completed by the auto-purge schedule (PR #770): archiving is a soft delete with a fixed 120-day retention window, a warning email is sent on day 90, and a daily cron permanently purges anything that reaches day 120.
Goals and Non-Goals:
Bulk selection is available on four creator surfaces: the Quests tab, the Adventures tab, the Archive tab, and the Background Assets pages (library + archive).
Bulk actions: Archive (quests/adventures/assets), Restore (archive only), Delete — permanent, gated by a typed
DELETEconfirmation.All bulk routes accept an ID array (
content_ids/asset_ids) while remaining backward-compatible with the single-ID payloads (content_id/asset_id) used by per-item actions.Ownership is verified for every selected item before any write; a single unauthorized item fails the whole request (no partial unauthorized mutations).
Responses report the exact ID sets the server changed (
archivedIds/restoredIds/deletedIds) plusmissingIds/skippedIds; the client removes from state only what the server confirms.The archive lifecycle is fully automated: 120-day retention, a day-90 warning email grouped per creator, and a day-120 purge that deletes database rows first and then cleans storage.
Restored quests and adventures return to the Root of the library — their previous folder assignment is cleared.
Destructive confirmation is standardized on the shared
ConfirmDestructiveModal(typed token) with a server-side Zod double-check.Non-goals: cross-page or whole-library selection (selection is bound to the rendered, filtered list); bulk Move (shipped with MFPS PR #745, documented in the MFPS Internal Technical Guide — the action bar is shared between both features); undo/restore after a permanent delete; configurable retention per agency or content type (fixed at 120 days); bulk actions on reviewer, learner, or admin surfaces.
Glossary:
Archive — soft delete. Quests/adventures set
publishing_status = 'archived'and stamparchived_at; background assets setdeleted_at(andexpires_at).Purge — permanent delete of the database row plus its media from the
public-assetsbucket.Retention window — 120 days from the archive timestamp (
ARCHIVE_RETENTION_DAYSinlib/archive/getDaysRemaining.ts).Selection mode — the UI state while at least one item is selected; per-item action buttons hide and the floating action bar appears.
Typed confirmation — the destructive modal requires the literal token
DELETE; the server re-validates the token independently.Skipped ID — an item that was found but was already in the target state (already archived, or not archived when restoring).
Missing ID — a requested ID that was not found (already deleted or not visible to the requester).
Agency-scoped ownership — a user may act on a resource if they created it, or if it belongs to a creator in the same agency.
Folder-admin fallback (MFPS) — for quests and adventures only, a folder owner/admin (including inherited authority) may bulk-act on content contained in their folders.
Bulk bar —
SelectionActionBar, the floating contextual action bar shared by bulk actions and bulk move.
HIGH-LEVEL ARCHITECTURE
System Diagram:
Frontend: Next.js (App Router), React, TypeScript, Tailwind CSS, Shadcn/UI components (Button, Card, Checkbox, Dialog, Input, Label), Sonner toast notifications. Selection UI lives in components/creator/SelectionActionBar.tsx, components/creator/MyContent.tsx, components/creator/TrashContent.tsx, components/creator/background-assets/AssetList.tsx, app/creator/archive/page.tsx, with checkboxes rendered by components/content-library/ContentCard.tsx and components/creator/ContentListRow.tsx.
Backend: Next.js route handlers with Zod validation in shared/schemas/contentManagementSchema.ts, standardized responses via ApiResponseHelper (lib/api/response.ts), creator auth and agency ownership checks in lib/auth/agencyOwnership.ts, folder fallback via canManageItemsInFolders (lib/folder/folderAccess.ts), and archive domain logic in lib/archive/.
Database: Supabase Postgres. quests and adventures carry publishing_status, archived_at, and deletion_warning_sent_at; asset_metadata carries deleted_at, expires_at, and deletion_warning_sent_at. No schema change was required for bulk itself — the bulk layer reuses the columns introduced by the archive lifecycle.
Storage: Supabase Storage bucket public-assets. Purge deletes database rows first and then removes media only for the rows that were successfully deleted.
Authentication note: The platform uses Supabase Auth. Creator routes resolve the session through getCreatorAuthContext(), which maps the Supabase user to app_users. The legacy column names clerk_id / user_clerk_id still exist and store Supabase Auth UUIDs — they are naming artifacts only and must never be treated as Clerk IDs. The server uses the service-role Supabase client (lib/supabase.ts), so route-handler authorization is the enforcement point; RLS is defense-in-depth for direct PostgREST access.
Scheduled jobs: Two GitHub Actions workflows run daily at 10:00 UTC against develop (:3000) and staging (:3001): cron-pending-deletion.yml (day-90 warning emails) and cron-purge-expired.yml (day-120 purge). Both authenticate with Authorization: Bearer $CRON_SECRET.
DETAILED DESIGN & IMPLEMENTATION
Bulk Selection Model
Surfaces and wiring (current code):
Surface | Component | Selection state | Bulk actions |
|---|---|---|---|
Content Library → Quests / Adventures tabs |
|
| Archive, Delete |
Content Library → Archive tab (quests + adventures mixed) |
| explicit | Restore, Delete |
Background Assets library |
| explicit | Archive, Delete |
Background Assets archive ( |
| explicit | Restore, Delete |
Interaction rules:
Each card/row renders a 24×24 checkbox overlay in the top-left corner. It is
role="checkbox", focusable (tabIndex={0}), exposesaria-checked, and toggles on click or onEnter/Space.Checkboxes are revealed on hover (opacity 60% → 100%). They stay fully visible whenever selection mode is active, and forever at 60% opacity on the Quests/Adventures tabs in
MyContent(alwaysShowCheckbox). On the cards ofTrashContentthe checkbox takes the place of the expiry pill on hover.Clicking any checkbox enters selection mode. On
MyContentthe mode is derived from a non-empty selection, so deselecting everything returns the grid to normal.Shift+click selects the inclusive range between the last toggled index and the clicked index, using the currently rendered list order (additive — it does not deselect).
Select All selects every ID in the currently filtered/rendered list (search, status, skills, and sort filters are respected). Deselect All clears the set but keeps the bar open until the user exits via the X button.
While selection mode is active: selected cards get a
ring-2 ring-blue-500highlight, per-item action buttons are hidden, and card clicks toggle selection instead of opening the item.After every bulk action the client removes only the IDs returned by the server (
archivedIds/restoredIds/deletedIds) and then clears the selection.The confirmation modal is a Radix Dialog, so
Escape/ overlay click closes it by default; theisSubmittingguard blocks closing while a request is in flight.
SelectionActionBar props (shared with bulk Move):
Prop | Purpose |
|---|---|
| Visibility and the “N items selected” live count |
| Select all filtered / clear the set |
| Archive button (quests/adventures/assets library) |
| Restore button (both archive views) |
| Move button — rendered only by folder-enabled surfaces (MFPS, PR #745) |
| Delete (opens the confirmation modal) / exit selection mode |
The bar is fixed to the bottom-center of the viewport (fixed bottom-6 left-1/2 -translate-x-1/2 z-50) with brand-navy styling, so entering selection mode never pushes the content grid down. It renders role="toolbar" with aria-live="polite" on the count and wraps on small screens.
Database Schema
Archive columns (from the Archive Asset lifecycle, reused by bulk actions):
-- quests / adventures (20260728, backfilled 20260729)ALTER TABLE quests ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;ALTER TABLE adventures ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;CREATE INDEX idx_quests_archived_at ON quests (archived_at);CREATE INDEX idx_adventures_archived_at ON adventures (archived_at); -- warning tracking (20260729) — quests, adventures, asset_metadataALTER TABLE quests ADD COLUMN deletion_warning_sent_at TIMESTAMPTZ DEFAULT NULL;ALTER TABLE adventures ADD COLUMN deletion_warning_sent_at TIMESTAMPTZ DEFAULT NULL;ALTER TABLE asset_metadata ADD COLUMN deletion_warning_sent_at TIMESTAMPTZ DEFAULT NULL; CREATE INDEX idx_quests_deletion_warning ON quests (archived_at) WHERE publishing_status = 'archived' AND deletion_warning_sent_at IS NULL;CREATE INDEX idx_adventures_deletion_warning ON adventures (archived_at) WHERE publishing_status = 'archived' AND deletion_warning_sent_at IS NULL;CREATE INDEX idx_asset_metadata_deletion_warning ON asset_metadata (deleted_at) WHERE deleted_at IS NOT NULL AND deletion_warning_sent_at IS NULL; -- asset soft delete (pre-existing, 20260424)ALTER TABLE asset_metadata ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL;ALTER TABLE asset_metadata ADD COLUMN expires_at TIMESTAMPTZ DEFAULT NULL;CREATE INDEX idx_asset_metadata_deleted_at ON asset_metadata (deleted_at);
Implementation notes:
publishing_status = 'archived'is the authoritative archive flag for quests/adventures;archived_atis the timestamp that drives the countdown and purge. Rows archived before the column existed were backfilled witharchived_at = updated_at.Background assets use
deleted_atas the archive flag.expires_atis written asdeleted_at + 120 days, but the purge selects ondeleted_at—expires_atis informational only.deletion_warning_sent_atprevents duplicate warning emails and is set only after the email is sent successfully.Retention is application-side:
ARCHIVE_RETENTION_DAYS = 120inlib/archive/getDaysRemaining.ts(single source of truth).
Migrations involved (apply in order; PR #728 added none):
Migration | Purpose |
|---|---|
|
|
|
|
| Backfill |
API Specification
All content and asset routes return the standard envelope from ApiResponseHelper: { success, message, data, error }. The two asset mutation routes that predate the helper (delete-assets, restore-assets) return the same shape via raw NextResponse ({ success, message, data }).
Endpoint matrix:
Method | Endpoint | Purpose | Body schema | Success data |
|---|---|---|---|---|
PATCH |
| Archive quests/adventures |
|
|
PATCH |
| Restore archived quests/adventures |
|
|
DELETE |
| Permanently delete quests/adventures |
|
|
DELETE |
| Archive (soft delete) assets |
|
|
PATCH |
| Restore archived assets |
|
|
DELETE |
| Permanently delete assets |
|
|
GET |
| Archived quests + adventures for the Archive tab | — |
|
GET |
| Archived assets for the assets archive page | — | Normalized asset array |
PATCH /api/creator/archive-content
Body: { content_ids: string[] } or { content_id: string } (UUID) + content_type: "quests" | "adventures".
Fetches the existing rows with their folder columns, verifies ownership per item, filters out already-archived rows, then updates publishing_status = 'archived', archived_at = now, updated_at = now.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid archive request payload / No content IDs provided / Content is already archived |
403 | You do not have permission to archive this content |
404 | Content not found or access denied |
500 | Failed to archive content: <database message> |
PATCH /api/creator/restore-content
Body: same shape as archive. Restores archived rows to publishing_status = 'draft', clears archived_at, and clears the folder association (quest_folder_id / adventure_folder_id and project_folder_id → null) so the item returns to the Root of the library.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid restore request payload / No content IDs provided / Content is not archived |
403 | You do not have permission to restore this content |
404 | Content not found or access denied |
500 | Failed to restore content |
DELETE /api/creator/permanent-delete
Body: { content_ids } or { content_id } + content_type + confirm_text (required; must be exactly DELETE). Ownership is checked per item; the DB delete is a single .in("id", ids) statement. Afterwards, storage cleanup runs for each deleted item at {content_type}/{id}: it lists the folder, deletes matching asset_metadata rows (file_path LIKE '{path}/%') for the item's creator (not the requester), and removes the storage objects. A failed token writes an audit_logs row (action: PERMANENT_DELETE_FAILED) and returns 400.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid permanent delete request payload / No content IDs provided / Please type "DELETE" to confirm permanent deletion |
403 | You do not have permission to delete this content |
404 | Content not found or access denied |
500 | Failed to permanently delete content. Please try again. / Content not found. It may have been already deleted. |
DELETE /api/creator/delete-assets (archive)
Body: { asset_ids } or { asset_id }. Skips assets that already have deleted_at, then sets deleted_at = now and expires_at = now + 120 days.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid request payload / No asset IDs provided / Assets are already archived |
404 | Assets not found |
500 | Failed to delete asset |
PATCH /api/creator/restore-assets
Body: { asset_ids } or { asset_id }. Clears deleted_at and expires_at; reports found-but-not-archived items as skipped.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid request payload / No asset IDs provided / Assets are not archived |
404 | Assets not found |
500 | Failed to restore asset |
DELETE /api/creator/purge-assets (permanent)
Body: { asset_ids } or { asset_id } + confirm_text: "DELETE" (Zod literal). Ownership is verified per asset via verifyAgencyResourceOwnership (no folder fallback). Database rows are deleted first; storage removal runs only for the rows in the returned deletedIdSet. For videos, the .jpg poster sibling is removed as well.
Status | Return |
|---|---|
200 OK |
|
400 | Invalid permanent purge request payload / No asset IDs provided |
404 | Assets not found |
500 | Failed to permanently delete asset / Failed to delete from database |
API Helpers
getCreatorAuthContext()(lib/auth/agencyOwnership.ts) — resolves the Supabase Auth session into anAgencyAuthContext(userId,userInternalId,agencyId,isAgencyMember). ThrowsAuthenticationError(401) orAccountSuspendedError(403, mapped byApiResponseHelper.handleError).verifyAgencyResourceOwnership(context, resourceCreatorId)— returns the creator ID when the requester is the creator, or when the creator belongs to the same agency (checked throughagency_members,app_users.agency_id, and agency ownership); otherwise throwsAuthorizationError(403). Called once per selected item in every bulk route.canManageItemsInFolders(items, context)(lib/folder/folderAccess.ts) — MFPS folder fallback used by the quest/adventure routes: a folder owner/admin (including inherited authority) may archive, delete, or restore contained content even when they are not the creator. Background asset routes do not use this fallback.agencyResourceFilter(context)— builds the.or(...)filter used by the listing endpoints so agency members see the same-agency content in their Archive views.ApiResponseHelper(lib/api/response.ts) — the standardized envelope and status mapping (validationError400,forbidden403,notFound404,internalError500,handleErrorfor thrown errors). 5xx responses are logged server-side with stack traces.Zod schemas (
shared/schemas/contentManagementSchema.ts):archiveContentSchema,restoreContentSchema,permanentDeleteSchema,bulkAssetIdsSchema,purgeAssetsSchema(+ inferred typesArchiveContentInput,RestoreContentInput,PermanentDeleteInput,BulkAssetIdsInput,PurgeAssetsInput). Each accepts a single ID or an ID array and exposes arefinemessage (“Either content_id or content_ids is required” / “Either asset_id or asset_ids is required”).Archive domain:
ARCHIVE_RETENTION_DAYSandgetDaysRemaining(dateString, retentionDays?)(lib/archive/getDaysRemaining.ts),sortArchivedItems(lib/archive/sortArchivedItems.ts), andpurgeExpiredQuests/purgeExpiredAdventures/purgeExpiredAssets(lib/archive/purgeArchivedContent.ts).
Core Logic and Workflow
Bulk archive flow (quests / adventures):
The user enters selection mode and picks items;
SelectionActionBarappears.Archive sends
PATCH /api/creator/archive-contentwithcontent_idsand the activecontent_type.The route validates the payload and fetches all requested rows with their folder columns.
For each row: creator/agency ownership is checked first; on failure the route falls back to
canManageItemsInFolders. One unauthorized item → 403 for the whole request.Already-archived rows are excluded and reported as
skippedIds; rows that were not found are reported asmissingIds.The remaining rows are updated in one statement (
publishing_status,archived_at,updated_at).The client removes exactly
archivedIdsfrom state, shows a toast with that count, and clears the selection.
Bulk permanent delete flow (quests / adventures):
Delete opens
ConfirmDestructiveModalshowing the count and the scrollable list of selected titles; the confirm button stays disabled untilDELETEis typed.DELETE /api/creator/permanent-deletere-validates the payload, includingconfirm_text === "DELETE"; a mismatch writes thePERMANENT_DELETE_FAILEDaudit row and returns 400.Ownership/folder checks run for every item.
Rows are deleted in one statement and the deleted IDs are captured (
.select("id")).For each deleted item, storage cleanup runs at
{content_type}/{id}using the item's creator ID; cleanup failures are logged as warnings and never turn a successful delete into an error.The client removes
deletedIds, toasts the server-reported count, closes the modal, and clears the selection.
Bulk restore flow:
The Archive tab mixes quests and adventures, so the client groups the selection by
contentTypeand issues onePATCH /api/creator/restore-contentrequest per type; both must succeed (Promise.all+success:falsecheck).The route restores archived rows to
draft, clearsarchived_at, and clears folder columns.The client removes
restoredIdsand toasts “Restored N items”.
Asset flows: the same shape with the asset endpoints. Archive sets deleted_at / expires_at; restore clears them; purge deletes rows first and then removes storage for the deleted set only. Asset routes verify creator/agency ownership but have no folder fallback.
Archive retention lifecycle (per content type):
Day 0 — archive. A date-based
{days}d leftbadge (or “Today”) renders fromgetDaysRemaining(archived_at); three days or fewer is styled as expiring soon. New archives are immediately restorable.Day 90 — warning.
GET /api/cron/check-pending-deletionselects archived items witharchived_at <= now − (120 − 30) daysanddeletion_warning_sent_at IS NULL, groups them per creator, and sends oneAutoDeletionWarningEmaillisting every expiring item with a single “Go to Archive” link. The link is built fromNEXT_PUBLIC_APP_URL || APP_URL || "https://wyzquests.com"so it is always absolute. Sent items are stamped withdeletion_warning_sent_at.Day 120 — purge.
GET /api/cron/purge-expired-archivedcomputes the cutoffnow − 120 daysand runs the three purge functions. Each deletes the database rows first, then cleans storage withPromise.allSettled, and reports{ deleted, errors[] }per content type.Cron authentication. Both endpoints require
CRON_SECRET, accepted either asAuthorization: Bearer <secret>or the legacy?secret=query parameter. The GitHub Actions workflows deliberately use the header (PR #725): the secret may contain+, which a query string decodes as a space and would 401. Both workflows run daily at 10:00 UTC against develop (:3000) and staging (:3001) and fail the job on a non-200 response.
Partial-success semantics: archive and restore return skippedIds (found but already in the target state) and missingIds (not found); permanent delete and purge return missingIds. Bulk handlers derive their toast counts and state removal exclusively from the returned sets, falling back to the selected set only when the payload is absent. This is why a selection containing an already-archived item does not disappear from the UI while still existing server-side.
Confirmation & Destructive UX
Current implementation: destructive bulk actions use the shared components/shared/confirm-destructive-modal.tsx (ConfirmDestructiveModal, PR #780 / issue #777). It is fully controlled (open / onOpenChange), requires an exact confirmToken (case-insensitive mode optional) before enabling the destructive button, resets the token on every open, guards close/confirm while isSubmitting, and accepts an extraContent slot — used by the bulk flows to render a bounded, scrollable list of the selected item names keyed by stable IDs.
Server-side double-check: purgeAssetsSchema declares confirm_text: z.literal("DELETE"); permanentDeleteSchema requires a non-empty confirm_text that the route compares to DELETE and audits on failure. The modal is the client half only.
Superseded claim (stale facts corrected): PR #728 shipped a bespoke components/creator/BulkDeleteModal.tsx, and its description still says so. That component no longer exists: it was deleted when destructive paths were standardized on ConfirmDestructiveModal (PR #780) and its behavior (scrollable item list, required DELETE token, branded destructive styling) now lives in the shared modal. Similarly, PR #728's description mentions only Archive/Restore/Delete on the bar — the bar later gained Move (showMove / onMove, MFPS PR #745), and the “Restore All” label was shortened to Restore. The single-asset archive path for background assets still uses its own DeleteAssetModal (“Move to Archive”, 120-day copy); permanent purge uses the shared modal.
INFRASTRUCTURE & OPERATIONS
Dependencies:
Supabase Postgres (
quests,adventures,asset_metadata) and Supabase Storage (bucketpublic-assets).Supabase Auth for creator identity; the service-role Supabase client on the server.
EmailService(Emailit) and theAutoDeletionWarningEmailtemplate for day-90 warnings.GitHub Actions + SSH runner for the two cron workflows;
CRON_SECRETmust exist in the develop and staging env files.Zod for all payload validation; Shadcn/UI + Sonner for the UI.
Monitoring and Alerting:
No dedicated metrics. 5xx errors are logged server-side by
ApiResponseHelperwith stack traces; cron failures fail the GitHub Actions job, which is the primary alert surface.Watch 403s (“You do not have permission to …”) on bulk routes — they usually indicate an ownership/folder-fallback mismatch or a migration not yet deployed in that environment.
Watch 400s (“already archived” / “not archived”) — usually stale UI state after a concurrent action in another tab.
Watch the purge cron response JSON:
{ quests, adventures, assets }each report{ deleted, errors[] }. Non-emptyerrors[]means partial failures (fetch/DB); storage cleanup failures are logged as warnings only.Watch the warning cron counters
sent/skipped/errors;skippedmeans the creator has no email,errorsusually points at the email provider or branding lookup.audit_logsrows withaction = PERMANENT_DELETE_FAILEDindicate typed-confirmation failures (or probing).
Deployment Plan:
Apply the three migrations in order where they are not already deployed (see the migration table). They are prerequisite for the archive columns and the warning/purge crons, not for the selection UI itself.
The bulk endpoints are backward-compatible with the single-ID payloads, so backend and frontend can be deployed together or in either order.
Ensure
CRON_SECRETis present in the develop/staging env files and that both workflows are enabled.No feature flags. No production cron target exists in these workflows (develop
:3000and staging:3001only) — confirm whether production purge/notification is expected to run from these workflows before relying on it.Verify retention behavior end-to-end on a seeded environment: archive → countdown badge → (simulate a 90-day-old timestamp) warning email → (simulate a 120-day-old timestamp) purge.
Run the QA matrix below and confirm toasts, counts, and state removal match the server responses.
TESTING AND QUALITY ASSURANCE
Testing Strategy (manual):
# | Scenario | Expected |
|---|---|---|
1 | Hover a quest/adventure card | Checkbox appears (60% idle on the library tabs), click enters selection mode and the floating bar appears |
2 | Shift+click across the grid / list | Inclusive range is added to the selection |
3 | Tab through checkboxes and press Enter / Space | Selection toggles; |
4 | Select All after applying a search / status / skills filter | Only the filtered, rendered items are selected |
5 | Deselect All | Selection clears; the bar stays open until X |
6 | Bulk Archive N quests | Countdown appears on the Archive tab; toast “Archived N quests”; items leave the active tab |
7 | Archive an already-archived item (stale UI) | Server reports it as skipped; the UI does not drop it from state based on the pre-request set |
8 | Bulk Delete with fewer than the required token | Confirm button stays disabled; direct API call with a wrong token → 400 + audit row |
9 | Bulk Delete with all items valid | Items removed; storage media and |
10 | Archive tab with a mixed quest + adventure selection | Two requests (one per type); both succeed and both ID sets are removed |
11 | Bulk Restore from the Archive tab | Items return to |
12 | Bulk archive/restore/delete background assets | Same behavior via the asset endpoints; archived assets leave the library grid and appear in |
13 | Agency member bulk-acts on a colleague's content (same agency) | Allowed; count and results match the server |
14 | Folder admin bulk-acts on contained quests/adventures they do not own | Allowed via |
15 | Unauthorized item included in a selection | Whole request fails with 403; no writes occur |
16 | Cron endpoints with a valid/invalid | 200 with per-type results / 401 |
Automated coverage:
There is no automated test coverage for the bulk paths (selection components, bulk routes, schemas, purge helpers). The nearest existing test is
tests/creator/api/background-assets/deleteAsset.test.ts, which covers the single-assetdeleteAssethelper (lib/backgroundAssets/tests/deleteAsset) and itsasset_idpayload.Relevant suites run with
npx jest tests/creatorandnpx jest tests/api.
Known Limitations:
Selection is bound to the currently rendered, filtered list; there is no cross-page or whole-library “select all”.
The retention window is fixed at 120 days (single application constant);
expires_aton assets is written but not read by the purge, which usesdeleted_at.The purge and warning workflows target develop and staging only; there is no production cron target in these workflow files.
Restoring clears the previous folder assignment by design — restored items always return to Root.
Permanent delete cannot be undone, and storage cleanup is best-effort (failures are logged, not retried; rows are deleted first so no broken references remain in the UI).
Assets have no folder-admin fallback; only the creator or a same-agency member may bulk-act on them.
No server-side cap on the number of IDs per request; a very large selection is sent as one request per content type.
Bulk progress is a single in-flight request per type — there is no per-item progress UI, and selection is not preserved across tab changes or refetches.
MAINTENANCE AND SUPPORT
Troubleshooting:
An item does not disappear after a bulk action → inspect the response
archivedIds/restoredIds/deletedIds; the client intentionally removes only those IDs.“Content is already archived” / “Assets are already archived” → the item state changed elsewhere (another tab/session); refresh and retry.
403 on a bulk action the user should be allowed to perform → check the folder fallback: quests/adventures call
canManageItemsInFolders; assets require creator or same-agency ownership.Agency member gets 403 → verify the creator's agency linkage (
agency_membersactive row,app_users.agency_id, or agency ownership);verifyAgencyResourceOwnershiprequires a same-agency match.Storage files remain after a purge → cleanup is best-effort and only logs warnings; the DB rows are already gone, so re-running the purge will not retry those files.
Wrong-token delete → expect 400 plus a
PERMANENT_DELETE_FAILEDrow inaudit_logs; verify the client sendsconfirm_textexactly asDELETE.Countdown badge missing or wrong → check
archived_aton the row (legacy archived rows needed the backfill migration;getDaysRemainingfalls back to the full retention window when it is null).Warning emails not sent → verify
CRON_SECRET(use the Bearer header; a+in the secret breaks the?secret=form),NEXT_PUBLIC_APP_URL/APP_URL(falls back tohttps://wyzquests.com), Emailit limits, and whetherdeletion_warning_sent_atwas already stamped.Purge does not run → check the workflow target ports (develop
:3000, staging:3001), that PM2 is serving those instances, and that the workflow'ssedextraction findsCRON_SECRETin the env file.Restored item appears at Root instead of its old folder → intended; the restore route clears folder columns.
Retention must change → update
ARCHIVE_RETENTION_DAYS; the countdown, warning threshold (retention − 30), and purge cutoff derive from it.
DOCUMENT VERSION
1.0 — Initial Internal Technical Guide for Bulk Actions (bulk select / delete / archive / restore for quests, adventures, and background assets), covering the archive lifecycle, retention cron jobs, API contracts, and verified current-code behavior. Written by Christian Denzon, 09/30/2026. PRs: #728, #711, #725, #726, #770.