From 565c7a512a7414f73166f11ebd72988572c026da Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Fri, 15 May 2026 18:02:02 +0530 Subject: [PATCH 1/3] feat: per-company screenshot retention policy + nightly cleanup cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Modules/ScreenshotRetention/controller.js | 178 ++++++ Modules/ScreenshotRetention/helper.js | 527 ++++++++++++++++++ Modules/ScreenshotRetention/init.js | 5 + Modules/ScreenshotRetention/routes.js | 17 + cron.js | 18 +- .../Setting/SettingScreenshotRetention.vue | 300 ++++++++++ frontend/src/locales/en.js | 20 + .../src/views/Settings/Setting/Setting.vue | 11 +- index.js | 1 + utils/mongo-handler/schema.js | 14 + 10 files changed, 1087 insertions(+), 4 deletions(-) create mode 100644 Modules/ScreenshotRetention/controller.js create mode 100644 Modules/ScreenshotRetention/helper.js create mode 100644 Modules/ScreenshotRetention/init.js create mode 100644 Modules/ScreenshotRetention/routes.js create mode 100644 frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue diff --git a/Modules/ScreenshotRetention/controller.js b/Modules/ScreenshotRetention/controller.js new file mode 100644 index 00000000..4558dd9a --- /dev/null +++ b/Modules/ScreenshotRetention/controller.js @@ -0,0 +1,178 @@ +/** + * Screenshot Retention — HTTP layer. + * + * All endpoints read the caller's companyId from the `companyid` request + * header (set by the existing JWT middleware) and the caller's userId from + * the request body / query — matching the convention used by other + * settings endpoints in this codebase. + * + * Mutation endpoints (preview, update) require the caller to be a company + * owner (`roleType === 1` in the per-tenant `company_users` collection). + * The frontend hides the card from non-owners; this is the defence-in-depth + * server check. + */ + +const helper = require('./helper'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const logger = require('../../Config/loggerConfig'); + +const ROLE_TYPE_COMPANY_OWNER = 1; + +// ----- Common helpers --------------------------------------------------- + +function getCallerContext(req) { + const companyId = req.headers && (req.headers.companyid || req.headers.companyId); + const userId = + (req.body && req.body.userId) || + (req.query && req.query.userId) || + (req.params && req.params.userId); + return { companyId, userId }; +} + +/** + * Confirm the caller is the company owner. Looks up the per-tenant + * `company_users` record by userId and checks roleType. Returns true / + * false; on lookup failure logs and returns false (deny on doubt). + */ +async function isCompanyOwner(companyId, userId) { + if (!companyId || !userId) return false; + try { + const query = { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [ + { userId: String(userId) }, + { _id: 1, roleType: 1, userId: 1 } + ] + }; + const record = await MongoDbCrudOpration(companyId, query, 'findOne'); + return !!record && Number(record.roleType) === ROLE_TYPE_COMPANY_OWNER; + } catch (err) { + logger.error(`[ScreenshotRetention] role check failed companyId=${companyId} userId=${userId} ${err && err.message}`); + return false; + } +} + +function sendErr(res, statusCode, message) { + return res.status(statusCode).send({ + status: false, + statusText: message, + message + }); +} + +// ----- Endpoints -------------------------------------------------------- + +/** + * GET /api/v1/screenshot-retention + * Returns the current policy for the caller's company. Any authenticated + * caller in the company can read it (the frontend needs to render the + * current state even for non-owners so they can see whether the policy is + * active — they just can't change it). + */ +exports.getSettings = async (req, res) => { + try { + const { companyId } = getCallerContext(req); + if (!companyId) return sendErr(res, 400, 'companyId header is required'); + + const policy = await helper.getCompanyPolicy(companyId); + return res.send({ + status: true, + statusText: 'OK', + data: { + policy, + validMaxAgeMonths: helper.VALID_MAX_AGE_MONTHS + } + }); + } catch (err) { + logger.error(`[ScreenshotRetention] getSettings error: ${err && err.message}`); + return sendErr(res, 500, err && err.message ? err.message : 'Failed to load settings'); + } +}; + +/** + * GET /api/v1/screenshot-retention/preview?maxAgeMonths=N&userId=... + * Returns { estimatedDeleteCount }. Used to surface a number in the + * confirmation dialog before flipping the toggle on. Owner-only because + * counting old trackshots involves a tenant DB aggregation and is non-trivial. + */ +exports.previewDeletion = async (req, res) => { + try { + const { companyId, userId } = getCallerContext(req); + if (!companyId) return sendErr(res, 400, 'companyId header is required'); + if (!userId) return sendErr(res, 400, 'userId is required'); + + const ownerOk = await isCompanyOwner(companyId, userId); + if (!ownerOk) return sendErr(res, 403, 'Only the company owner can preview screenshot retention'); + + const requested = Number(req.query && req.query.maxAgeMonths); + const maxAgeMonths = helper.VALID_MAX_AGE_MONTHS.includes(requested) + ? requested + : helper.DEFAULT_MAX_AGE_MONTHS; + + const cutoff = helper.computeCutoff(maxAgeMonths); + const estimatedDeleteCount = await helper.countOldTrackshots(companyId, cutoff); + + return res.send({ + status: true, + statusText: 'OK', + data: { + maxAgeMonths, + cutoffIso: cutoff.toISOString(), + estimatedDeleteCount + } + }); + } catch (err) { + logger.error(`[ScreenshotRetention] previewDeletion error: ${err && err.message}`); + return sendErr(res, 500, err && err.message ? err.message : 'Failed to preview'); + } +}; + +/** + * PUT /api/v1/screenshot-retention + * Body: { enabled?: boolean, maxAgeMonths?: number, userId: string } + * + * Updates the policy. Owner-only. When flipping from disabled→enabled we + * also stamp `enabledAt` / `enabledBy` so the audit trail is intact. + */ +exports.updateSettings = async (req, res) => { + try { + const { companyId, userId } = getCallerContext(req); + if (!companyId) return sendErr(res, 400, 'companyId header is required'); + if (!userId) return sendErr(res, 400, 'userId is required'); + + const ownerOk = await isCompanyOwner(companyId, userId); + if (!ownerOk) return sendErr(res, 403, 'Only the company owner can change screenshot retention'); + + const body = req.body || {}; + const patch = {}; + if (typeof body.enabled === 'boolean') patch.enabled = body.enabled; + if (body.maxAgeMonths !== undefined) { + const n = Number(body.maxAgeMonths); + if (!helper.VALID_MAX_AGE_MONTHS.includes(n)) { + return sendErr(res, 400, `maxAgeMonths must be one of ${helper.VALID_MAX_AGE_MONTHS.join(', ')}`); + } + patch.maxAgeMonths = n; + } + if (Object.keys(patch).length === 0) { + return sendErr(res, 400, 'No supported fields in body'); + } + + // Determine whether we're flipping from disabled → enabled so we can + // stamp enabledAt / enabledBy. Reading the prior state first is a + // small extra round-trip but worth the audit clarity. + const prior = await helper.getCompanyPolicy(companyId); + const markEnabled = patch.enabled === true && !prior.enabled; + + const updated = await helper.updateCompanyPolicy(companyId, patch, { markEnabled, userId }); + + return res.send({ + status: true, + statusText: 'Screenshot retention updated', + data: { policy: updated } + }); + } catch (err) { + logger.error(`[ScreenshotRetention] updateSettings error: ${err && err.message}`); + return sendErr(res, 500, err && err.message ? err.message : 'Failed to update settings'); + } +}; diff --git a/Modules/ScreenshotRetention/helper.js b/Modules/ScreenshotRetention/helper.js new file mode 100644 index 00000000..88fcbe3e --- /dev/null +++ b/Modules/ScreenshotRetention/helper.js @@ -0,0 +1,527 @@ +/** + * Screenshot Retention — helper layer. + * + * Owns: + * - reading and writing the per-company policy in the global `companies` + * master doc (`screenshotRetention` Map field); + * - counting deletable trackshots for the "preview" / confirmation dialog; + * - the actual nightly cleanup workflow used by cron.js. + * + * Design notes: + * - The toggle lives on the master companies document so the cron can + * enumerate opted-in companies in one query without opening every + * tenant DB to check a setting. + * - The cleanup deletes the Wasabi object(s) BEFORE pulling the trackshot + * subdoc from the tenant's TimeSheet. If Wasabi delete fails the DB + * record stays and the next nightly run retries. The reverse ordering + * would create permanent Wasabi orphans on every transient failure. + * - For each trackshot we delete the main key + every thumbnail variant + * (sizes from utils/thumbnail.json) because handleuploadMainFileForbase64Thumbnail + * materialises a thumbnail per declared size at upload time. + * - Filtering is by the trackshot's own `screenShotTime` (epoch ms) so a + * long-running TimeSheet doesn't keep old screenshots alive forever. + * - First-run safety cap prevents the very first cleanup from deleting + * hundreds of thousands of records in one go for legacy data; once the + * run finishes we stamp `firstRunCompletedAt` and lift the cap. + * - An advisory `runningSince` lock on the company doc prevents two cron + * invocations from colliding (e.g. a manual run kicking off while the + * nightly schedule fires). Stale locks (>4h old) are treated as crashed + * and reclaimed. + * + * This module is intentionally storage-aware: it imports the Wasabi delete + * primitive directly. The local-filesystem `STORAGE_TYPE=server` path is + * out of scope for retention today — companies on local storage simply + * never enable the toggle. + */ + +const { default: mongoose } = require('mongoose'); +const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const logger = require('../../Config/loggerConfig'); + +const LOG_PREFIX = '[ScreenshotRetention]'; + +// Mirror the trackshot entries declared in thumbnail.json. We hard-code them +// here so the cleanup is stable even if that JSON is reshuffled — if a new +// size is added at upload time but missed here, we'd just leave a few small +// thumbnails orphaned (not catastrophic; cheap to add later). +const TRACKSHOT_THUMBNAIL_SIZES = [ + { width: 78, height: 140 }, + { width: 118, height: 210 }, + { width: 218, height: 390 }, + { width: 479, height: 853 } +]; + +// UI offers a fixed set of retention windows. Anything outside the set is +// rejected at the API layer to keep the toggle predictable. +const VALID_MAX_AGE_MONTHS = [3, 6, 12, 24]; +const DEFAULT_MAX_AGE_MONTHS = 3; + +// Per-company guardrails for the cron loop. +const FIRST_RUN_MAX_DELETIONS = 50000; // cap the very first cleanup +const STALE_LOCK_THRESHOLD_MS = 4 * 60 * 60 * 1000; // 4h — assume crashed +const COMPANY_CONCURRENCY = 5; // how many tenants to process in parallel +const TENANT_BATCH_SIZE = 100; // TimeSheet docs per page + +// ----- S3 client (lazy) -------------------------------------------------- +// We reuse Modules/storage/wasabi/controller's S3 client at call time so +// any future credential rotation in awsRef propagates here without +// re-requiring this module. require() is cached, so this still resolves +// once per process. +let _s3 = null; +function getS3Client() { + if (_s3) return _s3; + // eslint-disable-next-line global-require + const wasabiCtrl = require('../storage/wasabi/controller'); + _s3 = wasabiCtrl.s3Client || wasabiCtrl.default && wasabiCtrl.default.s3Client; + if (!_s3) { + // Fallback: build our own from awsRef. This keeps the cron alive + // even if controller.js stops exporting s3Client in the future. + // eslint-disable-next-line global-require + const { S3Client } = require('@aws-sdk/client-s3'); + // eslint-disable-next-line global-require + const awsRef = require('../../Config/aws.js'); + _s3 = new S3Client({ + region: awsRef.region, + endpoint: awsRef.wasabiEndPoint, + credentials: { + accessKeyId: awsRef.wasabiAccessKey, + secretAccessKey: awsRef.wasabiSecretAccessKey + } + }); + } + return _s3; +} + +// ----- Policy read / write ---------------------------------------------- + +/** + * Read the policy for a company. Always returns a fully-shaped object — + * even when the field is absent on the master doc (legacy companies). + * Map-typed Mongoose fields surface as Map instances; we normalise to a + * plain object for the API response. + */ +async function getCompanyPolicy(companyId) { + const query = { + type: SCHEMA_TYPE.COMPANIES, + data: [ + { _id: new mongoose.Types.ObjectId(companyId) }, + { _id: 1, screenshotRetention: 1 } + ] + }; + const company = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, query, 'findOne'); + const raw = mapToObject(company && company.screenshotRetention); + return normalisePolicy(raw); +} + +/** + * Normalise to plain-object defaults so callers can rely on the shape. + */ +function normalisePolicy(raw) { + const policy = raw && typeof raw === 'object' ? raw : {}; + return { + enabled: policy.enabled === true, + maxAgeMonths: VALID_MAX_AGE_MONTHS.includes(Number(policy.maxAgeMonths)) + ? Number(policy.maxAgeMonths) + : DEFAULT_MAX_AGE_MONTHS, + enabledAt: policy.enabledAt || null, + enabledBy: policy.enabledBy || null, + lastRunAt: policy.lastRunAt || null, + lastRunStats: policy.lastRunStats || null, + firstRunCompletedAt: policy.firstRunCompletedAt || null, + runningSince: policy.runningSince || null + }; +} + +/** + * Mongoose Map → plain object. Returns null for missing values so the + * caller can fall through to defaults. + */ +function mapToObject(value) { + if (!value) return null; + if (value instanceof Map) return Object.fromEntries(value); + return value; +} + +/** + * Compute the cutoff Date for "older than N months". Anchored to "now" so + * the boundary slides forward each day. + */ +function computeCutoff(maxAgeMonths) { + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - Number(maxAgeMonths)); + return cutoff; +} + +/** + * Update the policy. Patch is a partial Map of fields to set; we whitelist + * the keys that may be touched to keep callers honest. `enabling=true` + * stamps enabledAt/enabledBy automatically. + */ +async function updateCompanyPolicy(companyId, patch, opts = {}) { + const setObj = {}; + if (patch.enabled !== undefined) setObj['screenshotRetention.enabled'] = patch.enabled === true; + if (patch.maxAgeMonths !== undefined) { + const n = Number(patch.maxAgeMonths); + if (!VALID_MAX_AGE_MONTHS.includes(n)) { + throw new Error(`Invalid maxAgeMonths ${patch.maxAgeMonths}`); + } + setObj['screenshotRetention.maxAgeMonths'] = n; + } + if (opts.markEnabled && opts.userId) { + setObj['screenshotRetention.enabledAt'] = new Date(); + setObj['screenshotRetention.enabledBy'] = String(opts.userId); + } + if (Object.keys(setObj).length === 0) return getCompanyPolicy(companyId); + + const query = { + type: SCHEMA_TYPE.COMPANIES, + data: [ + { _id: new mongoose.Types.ObjectId(companyId) }, + { $set: setObj }, + { new: true, useFindAndModify: false } + ] + }; + await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, query, 'findOneAndUpdate'); + return getCompanyPolicy(companyId); +} + +// ----- Counting (used by the "preview" / confirm dialog) ---------------- + +/** + * Count trackshots in the tenant's TimeSheets older than the cutoff. Uses + * an aggregation rather than `.find().forEach()` so large companies don't + * stream the full payload over the network. + */ +async function countOldTrackshots(companyId, cutoff) { + const cutoffMs = cutoff.getTime(); + const pipeline = [ + // Limit to docs that have at least one old trackshot. + { $match: { 'trackShots.screenShotTime': { $lt: cutoffMs } } }, + { $project: { + count: { + $size: { + $filter: { + input: { $ifNull: ['$trackShots', []] }, + as: 't', + cond: { $lt: ['$$t.screenShotTime', cutoffMs] } + } + } + } + } }, + { $group: { _id: null, total: { $sum: '$count' } } } + ]; + const query = { type: SCHEMA_TYPE.TIMESHEET, data: [pipeline] }; + try { + const result = await MongoDbCrudOpration(companyId, query, 'aggregate'); + if (Array.isArray(result) && result[0] && typeof result[0].total === 'number') { + return result[0].total; + } + return 0; + } catch (err) { + logger.error(`${LOG_PREFIX} countOldTrackshots failed companyId=${companyId} ${err && err.message}`); + return 0; + } +} + +// ----- Wasabi delete ---------------------------------------------------- + +/** + * Derive every key for a single trackshot — main object plus the four + * thumbnail variants — and try to delete them. Returns: + * { mainDeleted, thumbsDeleted, thumbsFailed, errors } + * + * Wasabi (S3-compatible) returns 204 for both "deleted" and "not found", + * so any non-throwing call counts as success. Transient errors surface + * as thrown exceptions and we treat the main delete as failed. + */ +async function deleteTrackshotObjects(companyId, trackshot) { + const s3 = getS3Client(); + const mainKey = extractKey(trackshot && trackshot.image); + if (!mainKey) { + return { mainDeleted: true, thumbsDeleted: 0, thumbsFailed: 0, errors: ['no-key'] }; + } + const thumbKeys = derivThumbnailKeys(mainKey); + + let mainDeleted = false; + let thumbsDeleted = 0; + let thumbsFailed = 0; + const errors = []; + + try { + await s3.send(new DeleteObjectCommand({ Bucket: companyId, Key: mainKey })); + mainDeleted = true; + } catch (err) { + errors.push(`main:${mainKey}:${err && err.message ? err.message : err}`); + } + + // Best-effort thumbnail cleanup. Don't gate the $pull on these; if a + // thumbnail delete fails today and the main key is gone the orphan is + // cosmetic, and the next run can't retry because the DB record is gone. + // Worth flagging in logs for ops to spot a pattern. + for (const key of thumbKeys) { + try { + // eslint-disable-next-line no-await-in-loop + await s3.send(new DeleteObjectCommand({ Bucket: companyId, Key: key })); + thumbsDeleted += 1; + } catch (err) { + thumbsFailed += 1; + errors.push(`thumb:${key}:${err && err.message ? err.message : err}`); + } + } + return { mainDeleted, thumbsDeleted, thumbsFailed, errors }; +} + +/** + * The `image` field on a trackshot has historically held either a Wasabi + * key (e.g. "trackshot/abc.png") or a fully-resolved URL (e.g. + * "https://s3.wasabi.../bucket/trackshot/abc.png"). Strip the URL prefix + * if present so we always send a pure key to DeleteObjectCommand. + */ +function extractKey(imageField) { + if (!imageField || typeof imageField !== 'string') return null; + const trimmed = imageField.trim(); + if (!trimmed) return null; + if (!/^https?:\/\//i.test(trimmed)) return trimmed; + try { + const url = new URL(trimmed); + // Path style: //. Virtual-host style: /. + // We don't know the bucket name without the company context here, + // so just return everything after the first slash that follows the + // host — covers both styles well enough for our cleanup. + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length <= 1) return parts.join('/'); + // Drop the first segment if it looks like a bucket id (24 hex chars). + if (/^[a-f0-9]{20,}$/i.test(parts[0])) parts.shift(); + return parts.join('/'); + } catch (e) { + return trimmed; + } +} + +/** + * Materialise the thumbnail keys for a given main key, mirroring the + * pattern from Modules/storage/wasabi/controller.js:210: + * `${name.split('.')[0]}-${width}x${height}.${ext}` + */ +function derivThumbnailKeys(mainKey) { + const lastDot = mainKey.lastIndexOf('.'); + if (lastDot <= 0) return []; + const base = mainKey.slice(0, lastDot); + const ext = mainKey.slice(lastDot + 1); + return TRACKSHOT_THUMBNAIL_SIZES.map(({ width, height }) => `${base}-${width}x${height}.${ext}`); +} + +// ----- Cleanup workflow (per company) ----------------------------------- + +/** + * Run the cleanup for a single company. Honours the company's policy + * (maxAgeMonths), the first-run cap, and the advisory lock. + * + * Returns the stats object that is also persisted on the master doc. + */ +async function runRetentionForCompany(company) { + const companyId = String(company._id); + const policy = normalisePolicy(mapToObject(company.screenshotRetention)); + if (!policy.enabled) return null; + + // Advisory lock — refuse to run if a previous run is still in flight + // and started recently. A run that started >STALE_LOCK_THRESHOLD ago is + // assumed crashed and gets reclaimed. + if (policy.runningSince) { + const lockAge = Date.now() - new Date(policy.runningSince).getTime(); + if (lockAge < STALE_LOCK_THRESHOLD_MS) { + logger.warn(`${LOG_PREFIX} skipping companyId=${companyId} — lock held since ${policy.runningSince}`); + return { skipped: 'locked' }; + } + logger.warn(`${LOG_PREFIX} reclaiming stale lock companyId=${companyId} lockAge=${Math.round(lockAge / 1000)}s`); + } + + const startedAt = Date.now(); + const cutoff = computeCutoff(policy.maxAgeMonths); + const cutoffMs = cutoff.getTime(); + const cap = policy.firstRunCompletedAt ? Infinity : FIRST_RUN_MAX_DELETIONS; + + let deletedCount = 0; + let failedCount = 0; + let scannedDocs = 0; + const failedKeys = []; + + try { + // Acquire lock. + await updateCompanyPolicy(companyId, {}, { /* no-op patch */ }); + await stampMasterField(companyId, { 'screenshotRetention.runningSince': new Date() }); + + // Loop through tenant TimeSheets in pages. Each page only loads docs + // that have at least one old trackshot. + let pagedSkip = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + if (deletedCount >= cap) { + logger.info(`${LOG_PREFIX} companyId=${companyId} hit first-run cap=${cap}; deferring rest to next run`); + break; + } + const query = { + type: SCHEMA_TYPE.TIMESHEET, + data: [ + { 'trackShots.screenShotTime': { $lt: cutoffMs } }, + { _id: 1, trackShots: 1 }, + { skip: pagedSkip, limit: TENANT_BATCH_SIZE, sort: { _id: 1 } } + ] + }; + // eslint-disable-next-line no-await-in-loop + const docs = await MongoDbCrudOpration(companyId, query, 'find'); + if (!Array.isArray(docs) || docs.length === 0) break; + scannedDocs += docs.length; + + // Process docs sequentially within a page so we don't fan out + // unbounded Wasabi requests; concurrency at the company level + // (COMPANY_CONCURRENCY) gives us enough parallelism overall. + for (const doc of docs) { + if (deletedCount >= cap) break; + const oldShots = (doc.trackShots || []).filter( + (t) => t && typeof t.screenShotTime === 'number' && t.screenShotTime < cutoffMs + ); + if (!oldShots.length) continue; + + const keysToPull = []; + for (const shot of oldShots) { + if (deletedCount >= cap) break; + // eslint-disable-next-line no-await-in-loop + const res = await deleteTrackshotObjects(companyId, shot); + if (res.mainDeleted) { + keysToPull.push(shot.image); + deletedCount += 1; + } else { + failedCount += 1; + failedKeys.push(...res.errors); + } + if (res.thumbsFailed) { + // Thumbnails are best-effort; just log and move on. + logger.warn(`${LOG_PREFIX} companyId=${companyId} thumb failures=${res.thumbsFailed} for main=${shot.image}`); + } + } + + if (keysToPull.length) { + // eslint-disable-next-line no-await-in-loop + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TIMESHEET, + data: [ + { _id: doc._id }, + { $pull: { trackShots: { image: { $in: keysToPull } } } } + ] + }, 'updateOne'); + } + } + // Next page. We sort by _id and don't rely on the unchanged-doc + // assumption, so use skip rather than a resumption cursor — the + // $pull above can move docs out of the match set, which is + // tolerable; we'll just see fewer hits on the next page. + pagedSkip += docs.length; + } + } catch (err) { + logger.error(`${LOG_PREFIX} runRetentionForCompany failed companyId=${companyId} ${err && err.message}`); + } + + const durationMs = Date.now() - startedAt; + const stats = { + deletedCount, + failedCount, + scannedDocs, + durationMs, + cutoffIso: cutoff.toISOString() + }; + + // Persist stats + clear lock + stamp first-run completion when applicable. + const finalPatch = { + 'screenshotRetention.lastRunAt': new Date(), + 'screenshotRetention.lastRunStats': stats, + 'screenshotRetention.runningSince': null + }; + if (!policy.firstRunCompletedAt && deletedCount < cap) { + finalPatch['screenshotRetention.firstRunCompletedAt'] = new Date(); + } + try { + await stampMasterField(companyId, finalPatch); + } catch (err) { + logger.error(`${LOG_PREFIX} could not persist run stats companyId=${companyId} ${err && err.message}`); + } + + logger.info(`${LOG_PREFIX} companyId=${companyId} deleted=${deletedCount} failed=${failedCount} scannedDocs=${scannedDocs} durationMs=${durationMs}`); + return stats; +} + +/** + * Helper: $set arbitrary screenshotRetention.* fields on the master doc. + * Kept separate from updateCompanyPolicy so the cron can stamp run-state + * fields without going through the policy validation surface. + */ +async function stampMasterField(companyId, setObj) { + const query = { + type: SCHEMA_TYPE.COMPANIES, + data: [ + { _id: new mongoose.Types.ObjectId(companyId) }, + { $set: setObj }, + { new: true, useFindAndModify: false } + ] + }; + return MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, query, 'findOneAndUpdate'); +} + +// ----- Cron entry point ------------------------------------------------- + +/** + * Enumerate all companies with retention enabled and process them with + * bounded parallelism. Invoked by cron.js nightly. + */ +async function runRetentionForAllCompanies() { + const startedAt = Date.now(); + let companies = []; + try { + const query = { + type: SCHEMA_TYPE.COMPANIES, + data: [ + { 'screenshotRetention.enabled': true }, + { _id: 1, Cst_CompanyName: 1, screenshotRetention: 1 } + ] + }; + companies = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, query, 'find'); + } catch (err) { + logger.error(`${LOG_PREFIX} could not enumerate opted-in companies ${err && err.message}`); + return; + } + + if (!companies.length) { + logger.info(`${LOG_PREFIX} no companies have retention enabled — nothing to do`); + return; + } + + logger.info(`${LOG_PREFIX} starting nightly run for ${companies.length} companies`); + + // Process companies in bounded batches. + for (let i = 0; i < companies.length; i += COMPANY_CONCURRENCY) { + const slice = companies.slice(i, i + COMPANY_CONCURRENCY); + // Promise.allSettled so one tenant's failure doesn't poison the batch. + // eslint-disable-next-line no-await-in-loop + await Promise.allSettled(slice.map((cmp) => runRetentionForCompany(cmp))); + } + + logger.info(`${LOG_PREFIX} nightly run complete in ${Math.round((Date.now() - startedAt) / 1000)}s`); +} + +module.exports = { + // policy + getCompanyPolicy, + updateCompanyPolicy, + normalisePolicy, + // preview + countOldTrackshots, + computeCutoff, + // cleanup + runRetentionForCompany, + runRetentionForAllCompanies, + // constants (handy for tests and the controller layer) + VALID_MAX_AGE_MONTHS, + DEFAULT_MAX_AGE_MONTHS +}; diff --git a/Modules/ScreenshotRetention/init.js b/Modules/ScreenshotRetention/init.js new file mode 100644 index 00000000..6396a4a4 --- /dev/null +++ b/Modules/ScreenshotRetention/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +}; diff --git a/Modules/ScreenshotRetention/routes.js b/Modules/ScreenshotRetention/routes.js new file mode 100644 index 00000000..fe5a36d0 --- /dev/null +++ b/Modules/ScreenshotRetention/routes.js @@ -0,0 +1,17 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + // Read the caller's company retention setting + last-run stats. + // Visible to any authenticated user in the company so we can render a + // disabled/read-only state for non-owners; mutation endpoints below + // enforce the owner check. + app.get('/api/v1/screenshot-retention', ctrl.getSettings); + + // Preview how many trackshots would be deleted at a given retention + // window. Used by the "are you sure?" dialog before flipping the toggle + // on. Owner-only. + app.get('/api/v1/screenshot-retention/preview', ctrl.previewDeletion); + + // Update the policy (enabled flag and/or maxAgeMonths). Owner-only. + app.put('/api/v1/screenshot-retention', ctrl.updateSettings); +}; diff --git a/cron.js b/cron.js index 8637b229..04b439ed 100644 --- a/cron.js +++ b/cron.js @@ -3,6 +3,7 @@ const logger = require("./Config/loggerConfig"); const taskIndexRef = require("./Modules/taskIndex/controller"); const { handleBucketSizeUpdateCron } = require(`./common-storage/common-${process.env.STORAGE_TYPE}.js`); const aiRef = require("./Modules/AI/controller") +const screenshotRetention = require("./Modules/ScreenshotRetention/helper"); // BUG-035 / #89 — pin every cron to a known timezone so schedules don't // shift when the server's local tz changes (DST transition, container @@ -18,9 +19,20 @@ schedule.scheduleJob({ rule: '0 0 * * *', tz: CRON_TZ }, async () => { handleBucketSizeUpdateCron(); }) -schedule.scheduleJob({ rule: '0 0 * * *', tz: CRON_TZ }, async () => { - logger.info(`Enter in cleanup trackshot schedule job`); - cleanUpTrackshot(); +// Screenshot retention — daily at 00:30 UTC, off-peak vs the other midnight +// jobs so a heavy cleanup doesn't compound with bucket-size + AI-reset on the +// same minute. Iterates every company that has the per-tenant policy +// enabled, deletes trackshots older than `maxAgeMonths` from both Wasabi and +// the tenant's TimeSheet.trackShots subdoc, then stamps run stats on the +// master companies doc. Replaces the previous `cleanUpTrackshot()` call that +// referenced an unimported binding and never actually ran. +schedule.scheduleJob({ rule: '30 0 * * *', tz: CRON_TZ }, async () => { + logger.info(`[Cron] screenshotRetention.runRetentionForAllCompanies`); + try { + await screenshotRetention.runRetentionForAllCompanies(); + } catch (err) { + logger.error(`[Cron] screenshotRetention failed: ${err && err.message ? err.message : err}`); + } }) // This cron job executes every 1 hour and Make Index for unIndex Task. diff --git a/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue b/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue new file mode 100644 index 00000000..3922f13c --- /dev/null +++ b/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 635d6beb..b783d77e 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -1652,6 +1652,26 @@ export default { ai_not_integrated: "AI is not integrated in your system", limit_reached: "You have reached your limit", }, + ScreenshotRetention: { + heading: "Auto-delete old screenshots", + subtitle: "When enabled, time-tracker screenshots older than the retention window are permanently deleted every night.", + toggle_label: "Enable auto-deletion", + toggle_hint: "Existing screenshots older than the window are removed on the next nightly run.", + window_label: "Retention window", + window_hint: "How long screenshots are kept before they're eligible for deletion.", + months_option: "{n} months", + confirm_title: "Permanently delete old screenshots?", + confirm_body: "Enabling this will delete approximately {count} screenshots older than {months} months on the next nightly run. This cannot be undone.", + confirm_yes: "Enable and delete", + saved: "Screenshot retention saved", + save_failed: "Could not save retention settings", + last_run: "Last run {when} — deleted {deleted} screenshots.", + not_run_yet: "First cleanup runs tonight at 00:30 UTC.", + today: "today", + yesterday: "yesterday", + days_ago: "{n} days ago", + months_ago: "{n} months ago", + }, Toast: { Your_card_is_expired: "Your card is expired.", Password_set_new_has_been_successfully: diff --git a/frontend/src/views/Settings/Setting/Setting.vue b/frontend/src/views/Settings/Setting/Setting.vue index f16288e0..0ca0d258 100644 --- a/frontend/src/views/Settings/Setting/Setting.vue +++ b/frontend/src/views/Settings/Setting/Setting.vue @@ -19,6 +19,13 @@ + + @@ -35,6 +42,7 @@ import SettingMilestoneWeeklyRange from "@/components/molecules/Setting/SettingM import SettingMilestoneStatus from "@/components/molecules/Setting/SettingMilestoneStatus.vue"; import settingFileExtesnsions from "@/components/molecules/Setting/SettingFileExtensions.vue"; import SettingCurrency from "@/components/molecules/Setting/SettingCurrencys.vue"; +import SettingScreenshotRetention from "@/components/molecules/Setting/SettingScreenshotRetention.vue"; import { defineComponent} from "vue"; import { useCustomComposable } from '@/composable'; const accesDenied = require("@/assets/images/access_denied_img.png"); @@ -47,7 +55,8 @@ defineComponent({ SettingTaskPriorityVue, SettingMilestoneWeeklyRange, settingFileExtesnsions, - SettingCurrency + SettingCurrency, + SettingScreenshotRetention } }) diff --git a/index.js b/index.js index a9c56a50..8726277b 100644 --- a/index.js +++ b/index.js @@ -141,6 +141,7 @@ function initializeControllers() { require('./Modules/notification/sendEmail/init').init(app); require('./Modules/trackerUserPermission/init').init(app); require('./Modules/SaasAdmin/init').init(app); + require('./Modules/ScreenshotRetention/init').init(app); if(process.env.NODE_ENV === "production") { require('./cron.js') } diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index 1df08b0e..1c08790b 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -565,6 +565,20 @@ const schema = { type: Map, required: false }, + // Per-company opt-in retention policy for time-tracker screenshots. + // Map shape: + // enabled: Boolean + // maxAgeMonths: Number (3 | 6 | 12 | 24) + // enabledAt: Date + // enabledBy: String (userId) + // lastRunAt: Date + // lastRunStats: { deletedCount, failedCount, durationMs, cutoffIso } + // firstRunCompletedAt: Date (used to lift the first-run safety cap) + // runningSince: Date (advisory lock — set when a run starts, cleared on finish) + screenshotRetention: { + type: Map, + required: false + }, availableUser: { type: Number, }, From 7597722b30ce3fe51e9a550aad11443ff1d0ec02 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Fri, 15 May 2026 18:15:17 +0530 Subject: [PATCH 2/3] refactor: comment out unused companyId injection in SettingScreenshotRetention component --- .../components/molecules/Setting/SettingScreenshotRetention.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue b/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue index 3922f13c..6480fcf3 100644 --- a/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue +++ b/frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue @@ -74,7 +74,7 @@ const $toast = useToast(); const { getters } = useStore(); const userId = inject('$userId'); -const companyId = inject('$companyId'); +// const companyId = inject('$companyId'); // Owner gate — mirrors the BillingHistoryTab convention. Component // renders nothing for non-owners (the