Skip to content

feat: per-company screenshot retention policy + nightly cleanup cron#157

Merged
parth0025 merged 3 commits into
stagingfrom
feature/screenshot-retention
May 18, 2026
Merged

feat: per-company screenshot retention policy + nightly cleanup cron#157
parth0025 merged 3 commits into
stagingfrom
feature/screenshot-retention

Conversation

@parth0025
Copy link
Copy Markdown
Collaborator

Adds an opt-in retention policy on the time-tracker screenshot pipeline. Visible on /settings/setting to the company owner (roleType === 1) only. When enabled, a nightly cron permanently deletes trackshots older than the configured window (3 / 6 / 12 / 24 months) from both the per-tenant TimeSheet.trackShots subdoc array and from Wasabi (main object + every thumbnail variant).

Backend

  • utils/mongo-handler/schema.js: new screenshotRetention Map field on the global companies schema. Stores enabled, maxAgeMonths, enabledAt, enabledBy, lastRunAt, lastRunStats, firstRunCompletedAt, runningSince. Backward-compatible — legacy companies without the field read as enabled: false.

  • Modules/ScreenshotRetention/: new module.
    helper.js — policy read/write, preview counter, per-company
    cleanup workflow, and the cron entry point.
    Production-ready guarantees:
    * Wasabi delete BEFORE db $pull so transient
    Wasabi failures leave the db record intact and
    the next nightly run retries (no permanent
    orphans).
    * Per-trackshot main + 4 thumbnail keys deleted
    (sizes hard-coded from thumbnail.json).
    * Filters by trackshot.screenShotTime (epoch ms),
    not parent TimeSheet timestamp.
    * Advisory runningSince lock prevents
    double-runs; stale locks (>4h) are reclaimed.
    * First-run safety cap (50k deletions) for the
    initial cleanup on legacy data; lifted once
    firstRunCompletedAt is stamped.
    * Bounded company concurrency (5 in parallel)
    via Promise.allSettled.
    controller.js — three endpoints with owner role check:
    GET /api/v1/screenshot-retention
    GET /api/v1/screenshot-retention/preview
    PUT /api/v1/screenshot-retention
    Owner check looks up the per-tenant
    company_users doc for the caller and confirms
    roleType === 1. Returns 403 on mismatch.
    routes.js — endpoint registration.
    init.js — module bootstrap (matches existing convention).

  • index.js: register the new module beside the rest of initializeControllers().

  • cron.js: removed the broken cleanUpTrackshot() call (was referencing an unimported binding and never executed). Added a new schedule at 00:30 UTC that invokes screenshotRetention.runRetentionForAllCompanies(). Off-peak vs the other midnight jobs so a heavy cleanup doesn't compound with the bucket-size + AI-reset jobs on the same minute.

Frontend

  • frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue: new card mounted on /settings/setting. Renders only for companyUser.roleType === 1. Toggle + retention-window dropdown + last-run telemetry. Enabling fires a SweetAlert confirmation that shows the preview-count from the new GET preview endpoint so the owner knows exactly what will be deleted on the next nightly run.

  • frontend/src/views/Settings/Setting/Setting.vue: mount the new component in the existing right-hand column. Self-hides for non-owners.

  • frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading, toggle/window labels, confirmation copy, last-run telemetry). Other locales fall back to English via vue-i18n.

Out of scope (deferred)

  • Translation backfill for non-English locales.
  • A RetentionAuditLog collection for per-run history beyond lastRunStats. The cron emits structured logs in the meantime.
  • A "dry run" mode that lets the owner see what would be deleted without enabling the policy. The preview endpoint already gives the count; a per-record preview would need its own UI.

Pull Request Template Chooser

Please click the link that matches your contribution type to load the correct format.

Note: Clicking a link will reload this page and clear any text you've already typed here.

  • Bug Fix
    Use this for fixing broken logic or UI glitches.

  • New Feature
    Use this for adding new functionality or components.

  • Refactor
    Use this for code cleanup, performance tweaks, or technical debt.


General Summary

If you don't want to use a specific template, please provide a brief summary of your changes below.

Adds an opt-in retention policy on the time-tracker screenshot pipeline.
Visible on /settings/setting to the company owner (roleType === 1)
only. When enabled, a nightly cron permanently deletes trackshots
older than the configured window (3 / 6 / 12 / 24 months) from both
the per-tenant TimeSheet.trackShots subdoc array and from Wasabi
(main object + every thumbnail variant).

Backend
=======
- utils/mongo-handler/schema.js: new `screenshotRetention` Map field
  on the global companies schema. Stores `enabled`, `maxAgeMonths`,
  `enabledAt`, `enabledBy`, `lastRunAt`, `lastRunStats`,
  `firstRunCompletedAt`, `runningSince`. Backward-compatible — legacy
  companies without the field read as `enabled: false`.

- Modules/ScreenshotRetention/: new module.
    helper.js   — policy read/write, preview counter, per-company
                  cleanup workflow, and the cron entry point.
                  Production-ready guarantees:
                    * Wasabi delete BEFORE db $pull so transient
                      Wasabi failures leave the db record intact and
                      the next nightly run retries (no permanent
                      orphans).
                    * Per-trackshot main + 4 thumbnail keys deleted
                      (sizes hard-coded from thumbnail.json).
                    * Filters by trackshot.screenShotTime (epoch ms),
                      not parent TimeSheet timestamp.
                    * Advisory `runningSince` lock prevents
                      double-runs; stale locks (>4h) are reclaimed.
                    * First-run safety cap (50k deletions) for the
                      initial cleanup on legacy data; lifted once
                      `firstRunCompletedAt` is stamped.
                    * Bounded company concurrency (5 in parallel)
                      via Promise.allSettled.
    controller.js — three endpoints with owner role check:
                    GET  /api/v1/screenshot-retention
                    GET  /api/v1/screenshot-retention/preview
                    PUT  /api/v1/screenshot-retention
                    Owner check looks up the per-tenant
                    `company_users` doc for the caller and confirms
                    `roleType === 1`. Returns 403 on mismatch.
    routes.js     — endpoint registration.
    init.js       — module bootstrap (matches existing convention).

- index.js: register the new module beside the rest of
  `initializeControllers()`.

- cron.js: removed the broken `cleanUpTrackshot()` call (was
  referencing an unimported binding and never executed). Added a new
  schedule at 00:30 UTC that invokes
  `screenshotRetention.runRetentionForAllCompanies()`. Off-peak vs the
  other midnight jobs so a heavy cleanup doesn't compound with the
  bucket-size + AI-reset jobs on the same minute.

Frontend
========
- frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue:
  new card mounted on /settings/setting. Renders only for
  `companyUser.roleType === 1`. Toggle + retention-window dropdown +
  last-run telemetry. Enabling fires a SweetAlert confirmation that
  shows the preview-count from the new GET preview endpoint so the
  owner knows exactly what will be deleted on the next nightly run.

- frontend/src/views/Settings/Setting/Setting.vue: mount the new
  component in the existing right-hand column. Self-hides for
  non-owners.

- frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading,
  toggle/window labels, confirmation copy, last-run telemetry). Other
  locales fall back to English via vue-i18n.

Out of scope (deferred)
=======================
- Translation backfill for non-English locales.
- A `RetentionAuditLog` collection for per-run history beyond
  `lastRunStats`. The cron emits structured logs in the meantime.
- A "dry run" mode that lets the owner see what would be deleted
  without enabling the policy. The preview endpoint already gives the
  count; a per-record preview would need its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@parth0025 parth0025 self-assigned this May 15, 2026
parth0025 and others added 2 commits May 15, 2026 18:15
Independent review of the original commit surfaced three blockers and
several correctness issues. This is a follow-up commit on the same
branch that addresses all of them.

BLOCKERS
========

1. **Routes had no JWT middleware.** Config/setMiddleware.js gates auth
   via path allowlists. The new `/api/v1/screenshot-retention` and
   `/api/v1/screenshot-retention/preview` paths were NOT listed in
   either `verifyJWTTokenWithCRoute` or `verifyJWTToken`, so the
   endpoints shipped completely unauthenticated — anyone on the
   internet could toggle retention for any company. Added both paths
   to `verifyJWTTokenWithCRoute` so the JWT middleware fires and
   populates `req.uid` / `req.aud`.

2. **Thumbnail keys were generated in the wrong dimension order.** The
   upload-time call at Modules/storage/wasabi/controller.js:298 passes
   `(thu.height, thu.width)` into a function whose params are
   `(width, height)`, so the stored filename is effectively
   `<base>-<height>x<width>.<ext>`. The cleanup helper was building
   `<base>-<width>x<height>.<ext>` and missing every thumbnail variant
   on every retention run — leaving four orphan Wasabi objects per
   trackshot, permanently. derivThumbnailKeys now mirrors the
   upload-time swap with a comment explaining the pre-existing bug
   in the upload path (out of scope to fix here).

3. **Cursor-based pagination replaces skip-based scan.** The previous
   loop advanced `pagedSkip += docs.length` after each batch, but
   the `$pull` inside the loop removes docs from the match set —
   meaning the next page's skip lands past where it should. Most of
   the DB was being missed on legacy-data tenants. Replaced with
   `_id > lastSeenId` cursor that's immune to in-flight match-set
   shrinkage.

SECURITY
========

4. **Controller now reads userId from `req.uid`** (set by
   Config/jwt.js#checkToken from the verified token's `uid` claim),
   not from `req.body.userId`. The old code let a non-owner pass the
   owner's userId in the body and pass the role check. Status codes
   updated: missing userId → 401 (authentication required) rather
   than 400.

HIGH
====

5. **Legacy string-typed `screenShotTime` is now handled.** Multipart
   form uploads coerce numbers to strings at write time, so the
   `screenShotTime` field on legacy trackshots is stored as a string.
   The previous `$lt: cutoffMs` (number) didn't match strings.
   - `countOldTrackshots` now uses `$convert` with
     `to: 'long', onError: null` inside the $filter so the comparison
     works regardless of the field's actual storage type.
   - `runRetentionForCompany`'s loose DB query + strict in-memory
     filter coerces via `Number(t.screenShotTime)` and skips
     non-finite values.

6. **`firstRunCompletedAt` is now stamped only when a real cleanup
   completed.** Previous condition `deletedCount < cap` stamped the
   marker on a zero-deletion run (e.g. a new company with no eligible
   data), which lifted the first-run safety cap before any real bulk
   data accumulated. New condition: scan exhausted (no cap hit, no
   error) AND deletedCount > 0.

7. **Empty-image trackshots no longer inflate `deletedCount`.** The
   scan loop now pre-filters them out (`oldShots` requires `t.image`),
   and `deleteTrackshotObjects` returns `skipped: true` rather than
   `mainDeleted: true` if it's ever called with a missing key.
   Stats now track `skippedCount` separately.

MEDIUM
======

8. **Removed the no-op `updateCompanyPolicy(companyId, {}, {})`
   call** that hit the empty-patch early-return at line 176 without
   writing anything. Lock acquisition is a single `stampMasterField`
   call, which is at least document-atomic. Cluster-aware leader
   election is still out of scope (documented).

9. **`lastRunStats` now records partial-run errors and cap-hit state.**
   New stats fields: `skippedCount`, `hitCap`, `error`. Ops can tell
   from the master doc whether the run completed cleanly, hit the
   cap, or threw mid-loop.

10. **Frontend now uses `watch(isOwner, …, {immediate: true})`** instead
    of one-shot `onMounted`. The Vuex `companyUserDetail` store
    sometimes hydrates after the component mounts on soft route
    changes; the one-shot mount would never call `loadPolicy()` in
    that case and the card stayed on hard-coded defaults forever.

LOW
===

11. **`extractKey` regex tightened** from `[a-f0-9]{20,}` to
    `[a-f0-9]{24}` (exact ObjectId length) and now decodes URL
    path segments so percent-encoded keys map back to their
    canonical storage form.

12. **`getS3Client` doc + body cleaned up.** `s3Client` is not exported
    from `wasabi/controller.js`, so the old "fall back if not
    available" branch was always taken. Removed the misleading
    primary path; we just build the client from `awsRef` and
    memoise it.

Test plan addendum
==================
- Hit `GET /api/v1/screenshot-retention` without a JWT → 401.
- Hit `PUT /api/v1/screenshot-retention` with a forged body
  `userId` of the owner from a non-owner JWT → 403 (controller
  uses `req.uid`, not body).
- On a tenant with legacy string-typed `screenShotTime`:
  - GET /preview returns a non-zero count for the configured window.
  - A nightly run deletes those records + their (height×width-named)
    thumbnail variants.
- Confirm thumbnail keys deleted in Wasabi by listing the bucket
  after a run — there should be no orphans for the deleted main
  keys.
- Confirm pagination handles a tenant whose every TimeSheet has at
  least one expired trackshot (all docs drop from match set on
  first $pull): the cursor variant should still terminate cleanly
  and process every doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@parth0025 parth0025 merged commit c01757b into staging May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant