feat: per-company screenshot retention policy + nightly cleanup cron#157
Merged
Conversation
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>
β¦Retention component
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>
joshishiv4
added a commit
that referenced
this pull request
Jun 16, 2026
* fix(stability): surface allSettled rejection reasons (BUG-018 / #72)
The audit description for BUG-018 β `Promise.allSettled rejection
branch never responds, outer Promise stays pending` β turned out to be
inaccurate in two ways once verified against the source:
1. The `else` branch on `if (rejected.length === 0)` already existed
and called `reject({status: false, statusText: 'error in
createProject'})`. The outer Promise did NOT hang.
2. `Promise.allSettled` never rejects in the first place β it always
resolves with the per-promise outcomes. So even without an else,
the surrounding `.then` would still fire.
What WAS broken (and is fixed here) is the loss of debugging info: the
existing else body was a generic statusText, with every per-query
`reason` in the `rejected` array discarded. The caller and the log
learnt nothing about WHICH prerequisite query failed or WHY.
Replace the else's reject body with a structured payload that includes:
- `rejectedCount` / `totalCount`
- `reasons`: array of `{index, reason}` carrying the actual error
message for each failed prerequisite query.
β¦and log the same array via `logger.error` so the failure is also in
the server log for ops.
Closes #72
* fix(stability): declare setChat with const (BUG-019 / #73)
`Modules/MainChats/controller.js:71` did:
setChat = await MongoDbCrudOpration(req.headers['companyid'], β¦);
with no `const`/`let`/`var`. That makes `setChat` an implicit global on
the Node process. Two concurrent requests share the same slot and can
clobber each other β the second response can include the first
request's data. (Under strict mode the assignment throws
`ReferenceError`; the file isn't strict today, so the live behaviour is
the silent cross-request leak.)
Trivial fix: add `const`. The variable is only used on the very next
line, so block scope is the right shape.
Closes #73
* fix(stability): align cache set/remove key in MainChats (BUG-020 / #74)
`Modules/MainChats/controller.js` had a cache-key mismatch between
writer and invalidator:
- `getChats` set the cache at
`mainChat:${req.headers['companyid']}`
- `updateMainChat` tried to invalidate at
`mainChat:${JSON.parse(JSON.stringify(result))?._id}`
β i.e. the updated document's `_id`, NOT the companyId.
The two keys never matched. `removeCache(...)` was a silent no-op and
stale chats stayed cached until the 3600s TTL expired.
Centralise the key in a small helper so both sites use the same shape:
const mainChatCacheKey = (companyId) => `mainChat:${companyId}`;
β¦and use it for both `myCache.set` and `removeCache`. The helper is
exported so the regression test can pin the shape.
Closes #74
* perf: add MongoDB indexes for hot query paths (BUG-021 / #75)
`utils/mongo-handler/createSchema.js` declared exactly one index β the
`sessions` TTL β and left every other collection un-indexed. Every
multi-field filter (tasks by project/sprint, history by task, users
by email, etc.) was a collection scan, scaling linearly with data
size.
Add compound + single-field indexes for the hottest filter paths the
codebase actually uses. ESR (Equality β Sort β Range) order where
applicable. Mongoose creates indexes at startup via `ensureIndexes`;
existing collections will see one-time background builds, which is
fine for any non-trivial dataset.
Per-company collections:
- tasks: (ProjectID, sprintId, deletedStatusKey)
(sprintId, deletedStatusKey)
(AssigneeUserId), (ParentTaskId), (TaskKey)
- comments: ('objId.taskId', deletedStatusKey)
('objId.sprintId'), ('objId.projectId')
- history: (TaskId, createdAt: -1), (ProjectId, createdAt: -1)
- timesheet: (TicketID), (userId, ProjectId)
- userId: (userId)
- sprints: (ProjectID, deletedStatusKey)
- folders: (ProjectID)
- projects: (deletedStatusKey)
Global DB collections:
- users: (Employee_Email), (AssignCompany)
The AssignCompany index is specifically required by the
BUG-013 per-request membership re-check that landed in
PR #114.
- userAuth: (email)
- companyUsers: (userId)
- sessions: (refreshToken), (userId) (TTL on createdAt already exists)
- resetAttempt: (ip)
Note on multi-tenancy: each company has its own MongoDB database, so
the document-level `companyId` field is redundant with the database
name and not indexed here. The audit's "missing companyId index"
framing was inaccurate; what's needed are the per-collection filter
indexes added above.
Closes #75
* chore(deps): upgrade sharp 0.32.6 β ^0.34.0 (BUG-022 / #76)
`sharp@0.32.6` carries CVE-2024-28219 (heap-buffer overflow via crafted
SVG). The fix was released in 0.33.2; bumping to the current major
(^0.34.0) β installed as 0.34.5 here β picks up that fix and several
subsequent security/perf releases.
The two existing callers in `Modules/storage/...` use the long-stable
`sharp(input).resize().withMetadata().toFile(...)` shape, which is
unchanged across 0.32 β 0.34, so no code changes are required.
A regression test at `.claude/tests/test-bug-022.js` (local, not
committed) confirms package.json + the installed module + a runtime
resize/metadata round-trip.
Closes #76
* fix(security): guard sharp() inputs against pixel-bomb DoS (BUG-023 / #77)
`Modules/storage/wasabi/controller.js` and
`Modules/storage/server/helpers/bucket.helper.js` invoke `sharp(buffer)`
and `sharp(file.path)` with no validation on either the input file size
or the image's pixel dimensions. An authenticated user could upload a
30000x30000 PNG and OOM the worker β `bodyParser` only limits the
request envelope, not what libvips materialises in memory.
New shared helper at `utils/imageGuard.js`:
- `guardFile(filePath)` β fs.statSync size check + metadata() check
- `guardBuffer(buffer)` β buffer-length check + metadata() check
- `getLimits()` reads `MAX_IMAGE_FILE_BYTES` and `MAX_IMAGE_PIXELS`
from env (defaults 25 MB and 50 megapixels). Both clamp to sane
minimums so a misconfigured 0 doesn't accidentally disable the
guard.
- On rejection, throws `ImageGuardError` with `statusCode: 413` and
a stable `code` (`IMAGE_TOO_LARGE` / `IMAGE_TOO_MANY_PIXELS`) for
the calling controller to surface to the client.
Wired into all five sharp() callsites:
- wasabi/controller.js: uploadThumbnailFile (file), uploadThumbnailFileFromBase64 (buffer)
- bucket.helper.js: uploadStorageThumbnailFile (file), uploadStorageThumbnailFilev2 (file/buffer)
The metadata-only sharp().metadata() call inside the guard does NOT
materialise the full image β it parses the header only, so the guard
itself cannot be DoS'd by the same input it's meant to reject.
`.env.example` documents the two new variables.
Closes #77
* perf: switch readFileSync in request handlers to async (BUG-024 / #78)
`Modules/storage/wasabi/controller.js` had five `fs.readFileSync` calls
inside request-handler flows (file uploads to Wasabi):
- updateLocalWasabiFiles (line 80)
- uploadThumbnailFile (line 619)
- uploadFileWasabiPromise (lines 695, 758)
- uploadPublicAssetsToWasabi (line 1010)
β¦and `Modules/notification/notification-middleware/controllerV2.js`
had a sixth `readFileSync` on every push-notification request to read
`brandSettings.json`.
Every one of these blocks the Node event loop for the duration of the
disk read. Under concurrent load, p99 latency on completely unrelated
routes spiked because they were stuck waiting on the event loop. The
sharp() resizing path was particularly bad because it reads the
resulting thumbnail back synchronously after writing it.
Switch each site to `await fs.promises.readFile(...)` and adjust the
surrounding Promise/callback structure:
- Promise constructors become `async (resolve, reject) => { β¦ }` so
`await` is legal inside.
- The sharp `.toFile(outputFile, async (err) => { β¦ })` callback
becomes async too.
- Each read is wrapped in try/catch that calls `reject` with the
underlying message instead of bubbling an uncaught exception.
- `uploadPublicAssetsToWasabi` was already `async`, so the change
there is a one-liner.
The notification handler additionally wraps the file-exists check in
try/catch so a permissions error doesn't unhandled-promise-reject and
crash the worker.
Closes #78
* fix(observability): route console.* through Winston (BUG-025 / #79)
Auth + MainChats had several `console.log` / `console.error` calls
that printed PII and error stacks to raw stdout, which in hosted
deployments lands in shared aggregators searchable by anyone with
log access.
Replace each call in:
- Modules/auth/controller/sendInvitation.js (5 sites)
- Modules/MainChats/controller.js (3 sites)
β¦with `logger.info` / `logger.error` from the existing Winston
config. Messages now go through the same redaction-able structured
pipeline as the rest of the app.
This PR covers the two files explicitly named in the audit. A
broader sweep across the codebase is possible but kept out of
scope to keep the diff reviewable.
Closes #79
* fix(stability): strict equality on isEmailVerified check (BUG-026 / #80)
`Modules/Users/controller.js:74` used `response.isEmailVerified ==
false`, which also matches `0`, `""`, `null`, `undefined`, `NaN`. A
user document missing the field entirely (legacy data, fresh records
before the field was added) would be classified as "Email Not
Verified" even though it was never explicitly false.
Swap to `=== false`. Documents with the field missing now fall
through to the next branch, which is the intended behaviour.
Audit-accuracy note: the audit referenced `Modules/usersModule/...`,
which doesn't exist after the naming-conventions refactor β the real
site is `Modules/Users/controller.js`. The audit mentioned the same
pattern was "widespread" but a fresh codebase grep finds this is the
only `== false` against `isEmailVerified`.
Closes #80
* fix(stability): guard checkUserAndCompany against duplicate res.send (BUG-027 / #81)
`Modules/Users/controller.js` `checkUserAndCompany` has six different
`res.send` sites across three nested .then / .catch chains. Today the
early-return pattern keeps them mutually exclusive in normal traffic,
but the structure is fragile:
- If `res.send` throws synchronously inside the inner `.then`
(e.g. ERR_HTTP_HEADERS_SENT triggered by upstream middleware), the
sync throw is caught by the inner `.catch`, which then calls
`res.send` AGAIN β a guaranteed cascade.
- Any future refactor that adds another branch without an explicit
`return` re-introduces the race.
Wrap every `res.send` through a local `sendOnce(payload)` helper backed
by a boolean flag. Duplicate writes are suppressed with a `logger.warn`
instead of throwing, and the request only ever produces one response.
(Also picks up a stray `== false` from BUG-026 in the same function
that was already fixed on another branch, harmless if both merge.)
Closes #81
* fix: correct copy-paste error in companyId validation message (BUG-028 / #82)
`Modules/logTime/controllerV2.js:77` returned "ProjectId is required"
when the missing field was actually companyId. One-character fix
(copy-paste from the adjacent projectId check at line 70).
Closes #82
* fix: validate timeDuration shape before .split(':') (BUG-029 / #83)
`Modules/logTime/controllerV2.js:162` did
`req.body.timeDuration.split(':')` after only a falsy-existence check
at line 53. That left two crash / data-corruption paths open:
- Non-string values that pass `!req.body.timeDuration` (e.g. an array
`[]`, an object `{}`, a number `42`) reach `.split` and either
crash with `TypeError: timeDuration.split is not a function` or
produce surprising arrays.
- A string without a colon (e.g. "1") returns `["1"]` from .split;
`diffArr[1]` is undefined, `+undefined` is NaN, and `diffMin` is
NaN. That NaN then writes straight into Mongo as the log duration.
Add an explicit shape check before the split: `typeof === 'string'`
AND matches `/^\d+:\d+$/`. Any other value gets a clean 400 with
"timeDuration must be a string in HH:MM format".
Closes #83
* fix(stability): guard against missing response.data in sprints (BUG-030 / #84)
`Modules/sprints/controller.js:45` did
const data = JSON.parse(JSON.stringify(response.data));
then read `data.projectCount.privateChannels` /
`data.planFeature.maxPrivateChannels`. If `response.data` was
undefined (race with a newly-created company, malformed write, etc.)
the surrounding Promise crashed with TypeError on the first nested
field access.
Guard up front:
- If `response` or `response.data` is null/undefined, log a warning
and resolve(false) (the "not allowed" outcome) instead of throwing.
- Default `projectCount` and `planFeature` to `{}` so individual
missing sub-fields evaluate to `undefined` instead of crashing.
The audit framed this as "JSON.parse throws on undefined" β actually
JSON.parse(JSON.stringify(undefined)) returns undefined silently. The
real crash was the subsequent property chain on the now-undefined
`data`.
Closes #84
* fix(observability): log silently-swallowed fetch failures (BUG-031 / #85)
`Modules/tasks/helpers/handleNotification.js:22-30` and the sibling at
line 33-43 had
.catch(error => { return null })
β silently turning every DB failure (fetchProjectDetailsSingle /
fetchTaskDetails) into "no data" downstream. The notification flow
continued with projectData=null / taskData=null and emitted
notifications with missing context.
Keep best-effort semantics (notifications shouldn't fail user
operations), but log via Winston so the failure is visible in ops.
Closes #85
* fix(BUG-032): consistent soft-delete filtering on list/count endpoints
Three list-style endpoints either lacked a soft-delete filter or used
an over-narrow form that excluded legacy documents (where the flag was
unset):
* Modules/Comments/controller.js β `getPaginatedMessages` previously
returned soft-deleted comments because it never matched on
`isDeleted`. Added `{ isDeleted: { $ne: true } }` to the aggregation,
matching `searchComments`. Also changed
`searchMessageFromMainChat`'s `isDeleted: false` to
`isDeleted: { $ne: true }` so legacy comments (no flag at all) keep
showing up.
* Modules/Project/controller/getSprintFolder.js β the `count` branch
matched on `projectId` only, so deleted sprints/folders inflated the
sidebar counters. Aligned with the listing branches
(`deletedStatusKey: { $nin: [1] }`).
* Modules/tasks/controller/getTabSyncTasks.js β `getTabSynctTaskWithTable`
used `{ deletedStatusKey: { $in: [0] } }`, which dropped any task
whose flag was unset. Sibling helpers (`getTaskCount`,
`getTabSynctTaskWithoutTable` via plain `0`) need the legacy/undefined
case to pass. Switched to `{ $in: [0, undefined] }` to bring it back
in line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-033): reconcile sprint task counts when secondary writes fail
The multi-step task flows in `Modules/tasks/helpers/mongo_helper.js`
(`moveTaskFunction`, `convertToListSubTask`, the merge-task path) all
follow the same shape: a primary findOneAndUpdate on a task, then one
or more independent `$inc` calls to keep the sprint's denormalised
`tasks` counter in sync. Each `$inc` is fire-and-forget β if it fails,
the count drifts away from the live tasks-collection state and the
sidebar shows the wrong number until someone notices.
A full MongoDB transaction would be the textbook fix, but
`MongoDbCrudOpration` does not thread a session through, and Mongo
transactions require a replica-set deployment that the self-hosted
footprint cannot guarantee. A self-healing reconcile is safer and
works in every deployment mode.
Changes:
- **Modules/tasks/helpers/reconcileTaskCount.js (new)** β
`computeLiveTaskCount(companyId, sprintId)` recomputes the canonical
count from the tasks collection (with the BUG-032 filter shape
`deletedStatusKey β {0, undefined}`).
`reconcileSprintTaskCount(...)` writes the recomputed value back.
`scheduleReconciliation(...)` runs it off the event loop via
`setImmediate` so callers don't have to chain another promise.
Helper is idempotent and best-effort: invalid input β null, errors
are logged but never rethrown.
- **Modules/tasks/helpers/mongo_helper.js** β
Wire `scheduleReconciliation` into every `updateSprintFun(...).catch`
in `moveTaskFunction`, `convertToListSubTask`, and the merge-task
flow, plus the outer `findOneAndUpdate` catch in `moveTaskFunction`
(where the source task is already soft-deleted before the destination
insert fails). On failure, the count is recomputed from source-of-
truth instead of being left drifted.
The audit's framing was slightly off β `HandleTask` itself only does a
save + history hooks, not the count updates. The actual drift risk
sits in the move/convert/merge flows downstream, which this commit
addresses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-034): rotate Winston log files
`Config/loggerConfig.js` wired three plain `transports.File` instances,
so `log/track.log`, `log/error.log`, and `log/combined.log` grew without
bound. On long-running deployments this eventually fills the disk and
crashes the process.
Replace each with a `winston-daily-rotate-file` transport that:
* Splits files by date (legacy basename preserved as a prefix:
`track-YYYY-MM-DD.log`, `error-β¦`, `combined-β¦`), so any existing
tail/grep / log-aggregator wiring keeps working.
* Caps each file at 20MB (`maxSize`) β env-tunable via LOG_MAX_SIZE.
* Keeps the last 14 days of rotated files (`maxFiles`) β env-tunable
via LOG_MAX_FILES.
* Gzips rotated files (`zippedArchive: true`) so old logs are cheap to
retain.
Also adds `winston-daily-rotate-file ^5.0.0` to dependencies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-035): pin cron jobs to a known timezone
`schedule.scheduleJob('0 0 * * *', β¦)` resolves the schedule in the
server's local timezone, so DST transitions or a container-host tz
change silently move the wall-clock firing time. Daily-midnight jobs
suddenly fire at 23:00 or 01:00 with no log trail.
Switch every `scheduleJob` call to the `{ rule, tz }` object form. The
tz defaults to UTC (the only zone that's stable everywhere), but
operators can override via the `CRON_TZ` env var when they need
calendar-day cuts in a specific region for accounting/billing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-036): path-traversal-safe loader for SERVICE_FILE
`Modules/checkinstallstep/controller.js` did
require("../" + process.env.SERVICE_FILE)
and `Config/firebaseConfig.js` did
require(path.resolve(__dirname, "..", config.SERVICE_FILE)).
Either lets an attacker (or a careless installer-wizard input) load
arbitrary JS/JSON inside the repo by stuffing `../`-style paths into
`SERVICE_FILE`. A `.js` file under the repo root would even be
executed with full process privileges.
Add `utils/safeServiceFile.js` exposing `resolveServiceFile()` which:
* rejects absolute paths,
* resolves the supplied path against the project root and verifies
the result is still inside it (no `..` escape),
* requires a `.json` extension (the Firebase service-account file
is always JSON; this blocks the arbitrary-`.js` execution path),
* verifies the file exists.
Wire the helper into both call sites so the same allow-list applies
whether SERVICE_FILE arrives via env at boot (`firebaseConfig.js`) or
during the installation wizard (`checkinstallstep/controller.js`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-037): tighten global bodyParser limit
Every endpoint accepted a 50MB JSON / url-encoded / raw body via
the global `bodyParser` middlewares. Any unauthenticated POST could
force the server to buffer the full 50MB before validation ran β
trivial memory DoS.
Drop the global default to 2MB (well above typical JSON payloads:
comments, settings, project data β but blocks the multi-MB DoS).
The body-parser cap is independent of multer, so file-upload routes
keep working under their own multer limits.
Operators with bulk-import use cases that legitimately need bigger
JSON can raise the cap via the `BODY_LIMIT` env var without code
changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-038): drop S3 request handler timeout from 5 min to 30 s
`Config/config.js` built `requestHandler` with both `connectionTimeout`
and `socketTimeout` set to 300_000 ms (5 minutes). A hung Wasabi call
pinned the request worker for the full five minutes β a brief upstream
outage was enough to saturate the worker pool and degrade unrelated
routes.
Drop both defaults to 30_000 ms (30 s). That covers normal multi-MB
uploads on slow links but gives up quickly on truly stuck connections.
Env-tunable via `S3_CONNECTION_TIMEOUT_MS` / `S3_SOCKET_TIMEOUT_MS`
for operators who need a different ceiling (e.g. very large file
uploads from poorly-connected mobile clients).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-039): server-side identity verification for social login
The frontend completes the OAuth dance client-side and POSTs
`{email, googleId}` or `{email, githubId}` to `/loginAuth`. The
backend trusted those values blind, so anyone who knew a victim's
email plus their numeric provider id could authenticate as them. The
audit's framing ("missing state parameter") was off for this
architecture β there is no backend-initiated OAuth redirect to attach
state to β but the underlying threat is real.
Add server-side verification gated behind env config so existing
deployments don't break on upgrade.
GitHub:
* `verifyGithubAccessToken(accessToken)` round-trips
`GET https://api.github.com/user` (HTTPS, 8 s timeout, structured
User-Agent + Accept headers).
* `verifyGithubAuth` rejects login unless GitHub's response carries
the same id and email as the client-supplied claim.
* Strict by default when an accessToken is present. Operators on
legacy clients can keep the old behaviour by setting
`GITHUB_OAUTH_REQUIRED=false` while they ship a frontend update.
Google:
* `verifyGoogleIdToken(idToken)` uses google-auth-library (already
a transitive dep) to validate signature, audience, and expiry,
then pulls the canonical `sub` and `email` from the verified
payload.
* `verifyGoogleAuth` rejects login on sub or email mismatch.
* Gated on `GOOGLE_OAUTH_CLIENT_ID` (the audience). If unset we log a
one-line warning and fall through to the legacy path so single-host
upgrades stay safe; `GOOGLE_OAUTH_REQUIRED=false` keeps the
legacy id-only behaviour when the client-id IS set but the client
hasn't been updated yet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-040): drop deprecated `atob` / `btoa` npm shims
Both packages have been runtime globals in Node since v16, so the npm
shims (`atob 2.1.2`, `btoa 1.2.1`) are dead weight in the lockfile and
haven't been maintained in years.
Replace the two call sites with a one-line `Buffer.from(...)`
expression that round-trips identically to what the shims did:
* Modules/auth/controller/verifyInvitation.js β decode base64 β
binary string for the invitation-blob parser.
* Modules/auth/controller/sendInvitation.js β encode binary β
base64 for the same blob construction.
`npm uninstall atob btoa` removes both packages from
package.json/package-lock.json.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-041): drop aws-sdk v2; consolidate on @aws-sdk/* v3
Both `aws-sdk` (v2, maintenance-only) and `@aws-sdk/*` (v3) were
installed and used in the same codebase. The only live consumer was
SES email sending in `Modules/servicewithAWS.js` (via
`Config/aws.js`'s exported `ses` client). The other v2 clients we
exported (`sesv2`, `ssmClient`, `s3`, `sesWithAttachment`) had zero
call sites β dead weight.
Migrate to @aws-sdk/client-ses (v3) and remove `aws-sdk` from
package.json.
Config/aws.js:
* Build the SES client via `new SESClient({...})` from
`@aws-sdk/client-ses`.
* Expose `awsRef.ses` as a v2-shaped shim: `sendEmail(params, callback)`
internally invokes `client.send(new SendEmailCommand(params))` and
fires the callback with `(err, data)` so the single existing caller
in servicewithAWS.js needs no rewrite.
* Also expose the raw v3 client as `awsRef.sesClient` for any future
code that wants to use Commands directly.
* Drop the unused exports (sesv2, ssmClient, s3, sesWithAttachment).
Modules/servicewithAWS.js:
* Remove the duplicate `aws-sdk` import and `AWS.config.update()` call
(v2-only).
* Switch the nodemailer `SES` transport to the v3-compatible
`{ SES: { ses, aws: require('@aws-sdk/client-ses') } }` shape for
the `sendAttachMail` path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-042): drop `moment` from backend; standardise on luxon
CLAUDE.md says the project standardised on Luxon. The backend still
imported `moment` in five files for trivial date formatting; this PR
removes those usages and drops `moment` from the root package.json.
Frontend `moment` usage (hundreds of call sites across Timesheet /
TimeLog / composables) is NOT touched here β that's a much larger
migration scoped separately. The frontend's own `package.json` keeps
its `moment` entry so the Vue build doesn't break.
New helper:
* `utils/dateHelpers.js` exposes
- `formatDate(input, fmt)` β accepts JS Date, millis, ISO string,
or `{seconds}` shape, and translates moment-style format tokens
(YYYY/MM/DD/HH/mm/ss/A/MMM/ddd) to luxon-style under the hood
so existing callers don't have to change their format strings.
- `formatNotificationDate(input)` β exact replacement for the two
`moment.calendar()` sites in `Modules/notification/sendEmail/`,
which both used the same format string for every branch (so the
relative-time wrapper was a no-op). Preserves the original
output, including the pre-existing `HH:MM` token quirk.
Migrations:
* `Modules/logTime/controllerV2.js` β single `.format("YYYY-MM-DD")`
call β `formatDate(date, 'yyyy-LL-dd')`.
* `Modules/notification/sendEmail/controller.js` and
`Modules/notification/sendEmail/controllerV2.js` β
`moment().calendar(...)` β `formatNotificationDate(...)`.
* `Modules/tasks/helpers/helper.js` and
`Modules/tasks/helpers/mongo_helper.js` β `changeDateFormat`
delegates to `formatDate` (which keeps the moment-style API its
callers pass).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-043): wrap clickable <span>/<img> affordances in real <button>
Two affordances dispatched click handlers on non-semantic elements
(no role, no aria-label, no keyboard focus, no screen-reader
announcement):
* `frontend/src/components/atom/Modal/Modal.vue` β the modal close
icon was a bare `<img @click="closeModal()">`. Wrap in
`<button type="button" :aria-label="$t('Projects.close') || 'Close'">`
with the icon inside as a presentational `<img alt="">`.
* `frontend/src/components/atom/Attachments/Attachments.vue` β the
"Download All" affordance was a `<span @click="downloadAllImages()">`.
Replace with `<button type="button" class="download-all-btn ...">`.
Both components' stylesheets gain a tiny rule to strip native button
chrome (so the visual result matches the old elements) but preserve
`:focus-visible` so keyboard users see the focused state.
Note: a couple of nearby affordances (Attachments' "See All" `<div>`
and the help-icon popover) have the same anti-pattern but are out of
scope here β the audit specifically flagged the `<span>` and `<img>`
cases above.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-044): give Modal role=dialog, aria-modal, and focus trap
The Modal atom rendered a bare `<div class="modal">` β no role,
no aria-modal, no aria-labelledby. Screen readers couldn't announce
the modal as a dialog and focus could leave the modal via Tab and
reach background controls.
Changes to `frontend/src/components/atom/Modal/Modal.vue`:
* Template:
- Root `<div class="modal">` gains `role="dialog"`,
`aria-modal="true"`, `:aria-labelledby="titleId"`, and
`tabindex="-1"` (so focus can land on the dialog itself when no
children are focusable).
- The title `<span>` now carries the `:id="titleId"` that
aria-labelledby points at.
- `@keydown.tab="handleTabKeydown"` traps Tab; `@keydown.esc.stop`
closes the modal on Escape.
* Script:
- `modalRef` template ref + `titleId` computed.
- `handleTabKeydown` cycles focus between the first and last
focusable descendants (queries the standard focusable selector
list, skipping aria-hidden and offscreen elements).
- `activateFocusTrap` captures the previously-focused element and
moves focus to the first focusable inside the modal (or the
dialog itself if there are none). Called on mount-if-open and
when `modelValue` flips to true.
- `deactivateFocusTrap` restores focus to the captured element on
close / unmount.
No new dependency: the trap is ~40 lines of inline logic; we don't
need `focus-trap` for one component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-045): wire up the jest test suite
`npm test` was a placeholder (`echo "Error: no test specified" && exit 1`)
even though jest was already installed as a devDependency. This PR
wires it up as a working bootstrap suite that future fixes can grow
into.
Changes:
* `package.json` β
- `test` now runs `jest`.
- new `test:watch` (development) and `test:naming` (the pre-existing
structural-audit test, kept off the default run because it flags
legacy naming inconsistencies tracked separately).
* `jest.config.js` β points jest at `tests/`, ignores
node_modules/frontend/installation/time-tracker-app/.claude and the
naming-conventions audit, and runs in `node` environment.
* `tests/smoke.test.js` β minimal guarantee the harness is alive
(always runs).
* `tests/utils/dateHelpers.test.js`, `tests/utils/safeServiceFile.test.js`,
`tests/utils/imageGuard.test.js` β behaviour tests for the helpers
introduced earlier in this audit (BUG-042, BUG-036, BUG-023). Each
suite is wrapped in a graceful skip when its target module isn't on
the current branch, so this PR lands cleanly today and the tests
light up automatically once the corresponding helper PRs merge to
staging.
`npm test` exits 0 β 3 passed (smoke) + 16 skipped (helpers pending
merge). After BUG-023/036/042 merge, all 19 should pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-046): let Mongoose manage history timestamps
`Modules/tasks/helpers/{helper,mongo_helper}.js`'s `HandleHistory`
wrote `createdAt` / `updatedAt` manually using `DateTime.utc().ts`,
which is a numeric millisecond value β not a BSON Date. Existing
documents ended up with inconsistent shapes (number vs Date) depending
on which code path created them.
Every other schema in `utils/mongo-handler/createSchema.js` is built
with `{ timestamps: true }` so Mongoose owns the timestamps. The
history schema alone was missing that option.
Changes:
* `utils/mongo-handler/createSchema.js` β add
`{ strict: false, timestamps: true }` to `historySchema`.
* `Modules/tasks/helpers/mongo_helper.js` (HandleHistory) and
`Modules/tasks/helpers/helper.js` (HandleHistory) β drop the manual
`createdAt: utcDateTime.ts, updatedAt: utcDateTime.ts` lines.
Mongoose populates both as BSON Dates on `.save()`.
* `utils/mongo-handler/schema.js` β keep `createdAt`/`updatedAt`
declared on the history shape so callers that read them still work,
but drop `required: true`. Mongoose sets them on `.save()`; making
them required on updates would reject legacy docs that never had
them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-047): ARIA semantics + keyboard nav for CustomDropDown
`frontend/src/components/molecules/DropDown/CustomDropDown.vue` is the
shared custom dropdown atom used across the app. Pre-fix:
* The trigger `<div @click="buttonClick()">` had no role,
no aria-haspopup, no aria-expanded, no tabindex β keyboard users
couldn't reach or open it.
* The floating panel had no role="listbox" β screen readers couldn't
identify it as a menu.
* The mobile-view close affordance was a bare clickable `<img>`.
Changes:
* Trigger gains role="button", tabindex="0", :aria-haspopup="'listbox'",
:aria-expanded (bound to open state), :aria-controls (panel id).
* Trigger handles keyboard:
- Enter / Space β open/close (mirrors click).
- Escape β close.
- ArrowDown β open AND move focus to the first focusable child
inside the options slot. (We can't enforce role="option" on
user-provided slot content, but moving focus there gets keyboard
users navigating immediately.)
* Floating panel gains role="listbox" and an @keydown.esc handler.
* Mobile close `<img>` wrapped in a real `<button type="button">`
with aria-label="Close" and a style rule (`.dropdown-close-btn`)
that strips native chrome but preserves :focus-visible.
* New helpers: `closeDropdown()` and `openAndFocusFirstOption()`.
Note: the sibling `DropDown.vue` and `MobileDropDown.vue` follow the
same anti-pattern but were not flagged by the audit; they'd benefit
from a similar sweep in a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Done - Test Case
Done - Test Case
* Add Gitigonre
Add Gitigonre
* Rename the folder Auth
Rename the folder Auth
* Rename Folder
Rename Folder
* Chnage the name
Chnage the name
* Folder Rename
Folder Rename
* remove dist
remove dist
* Create yml
Create yml
* Update main.yml
* Update main.yml
* Update main.yml
* Basic Setup
Basic Setup
* fix: repair broken advisory URL in SECURITY.md (#44)
The advisory link was split across two lines and wrapped in a code span,
rendering as malformed text rather than a clickable link. Joined the URL
and used proper markdown link syntax. Pairs with enabling Private
Vulnerability Reporting in repo settings so external reporters can
actually submit advisories.
Closes #44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: refresh correct lastRequest field on connection reuse (#42)
updateConnectionRecord was writing to lastRequset (typo) while the
idle-cleanup loop in startInterval reads lastRequest. Active company
databases therefore kept the timestamp frozen at createdAt and were
eligible for termination after the 30-minute window despite continuous
use. Fixed the property name so the update path and the cleanup path
reference the same field.
Closes #42
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: remove duplicate CheckInstallStep mount and .env hot-reload (#41)
Two related sources of duplicate-handler accumulation in the bootstrap:
1. CheckInstallStep was mounted twice: once inside
initializeControllers() and once unconditionally at the top level.
The top-level mount has to stay because the install wizard must be
reachable before MONGODB_URL is configured. Removed the inner mount
so each startup registers it exactly once.
2. fs.watchFile on .env called initializeControllers() on every save,
which re-ran .init(app) for ~60 route modules onto the same Express
app instance (Express has no clean route un-registration) and spun
another setInterval inside startInterval(). Removed the watcher;
nodemon already restarts on file changes in dev, and production
env changes require a process restart anyway.
After this change initializeControllers() is only invoked once, from
the MONGODB_URL startup gate.
Closes #41
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: preserve dataType and options across JWT refresh retry (#38)
apiRequest and apiRequestWithoutCompnay reconstruct the call after a
token refresh using only (type, endPoint, data), dropping dataType
and options. Any retry of a multipart upload then runs through the
JSON axios instance instead of the form-data instance, and any
caller-supplied abort signal or per-request option is lost. Forwarded
dataType and options into both retry calls so the replay matches the
original.
Closes #38
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: clean up close_click IPC listener after each notification (#39)
sendNotification() registered a new ipcMain.on('close_click', ...)
handler on every screenshot capture and never removed it, so the
process accumulated one global IPC listener per screenshot and a
single close click eventually fired N stale handlers, each trying to
close an already-destroyed BrowserWindow reference.
Switched the registration to ipcMain.once so the happy path
(user clicks close) auto-removes the listener. For the auto-timeout
path (window closes after 10s without a click) the handler stays
registered and would later steal a future notification's close
event, so also remove it explicitly when the window emits 'closed'.
The timeout id is now tracked and cleared on the same hook so the
timer can't fire against a destroyed window. The window reference
is captured in a local so each listener targets its own window even
if a subsequent sendNotification() reassigns the module-level
screenshotNotificationWindow before this listener fires.
Closes #39
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: allow editing the "Created by" user on a task
The task sidebar Details panel previously showed Created by (the
Task_Leader field) as a read-only user β set once at creation and
never changeable. Add an inline picker mirroring the existing
Assignee field so the creator can be reassigned.
Changes:
- Backend: new updateTaskLeader action on task_class_Mongo. Validates
the new Task_Leader, writes via $set, emits the same
socketEmitter.emit('update', { module: 'task', updatedFields }) used
by updateAssignee, and logs a TaskLeader_Changed history entry via
HandleHistory. Includes the isUpdateTask:false side-effects-only
branch for parity with updateStatus / updatePriority.
- Frontend store (TaskOperations): new updateTaskLeader action that
optimistically commits the new Task_Leader into the projectData
Vuex store, then PATCHes /api/v2/tasks with action 'updateTaskLeader'.
- TaskDetailRightSide.vue: replaced the read-only Created-by block
with an Assignee picker (single-select) for users holding the
task.task_assignee + task.task_list permissions, preserving the
original read-only display as the fallback for users without those
permissions. Wired @selected to a new updateTaskLeader() handler
with the same toast / error pattern as updateAssignee.
- i18n: added Toast.Created_by_updated_successfully and
Toast.Created_by_not_updated to the English locale. The other 10
locales fall back to English via vue-i18n until translators
backfill β flagged as a follow-up.
Permission policy: reuses task.task_assignee. Anyone allowed to
change the Assignee can also change Created by. No new permission
key, no role-permission migration. Backend has no per-field gate,
consistent with the existing Assignee / Status / Priority update
paths which all trust the frontend permission gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: split header notifications into Unread and Archive views
Previously the notification bell fetched every notification the user
had ever received (read or unread) in a single combined list, making
it hard to focus on actionable items. This change splits the dropdown
into two views β Unread (default on every open) and Archive β gated
by a single toggle button in the dropdown head.
Changes:
- Backend: GET /api/v1/app-notification/notification accepts a new
optional `filter` query param ('unread' | 'archived'). 'unread' adds
`notSeen: { $in: [userId] }` to the aggregation match; 'archived'
adds `notSeen: { $nin: [userId] }`. Default is 'unread' to keep the
bell focused on actionable items. Refactored the match clauses into
a `baseMatch` array so the filter is appended cleanly; query shape
and pagination are otherwise unchanged.
- Header.vue: added a `notificationFilter` ref ('unread' by default),
a "View Archive" / "View Unread" toggle in the dropdown head, a
`switchNotificationFilter()` handler that resets paging state and
refetches, and an `openNotificationsDropdown()` helper that the
bell click handlers now use so each fresh dropdown open lands in
Unread. `markRead()` and `markAllRead()` clear the read items from
the local list while the Unread filter is active so the dropdown
reflects the filter without an extra refetch. The "Mark all as
read" button visibility now keys off `notifications.length` (the
list is already filtered to unread on this view) rather than the
server-side `totalNotification` counter, which can lag and falsely
hide the button.
- i18n: added Header.View_Archive / Header.View_Unread to the English
locale; other locales fall back to English via vue-i18n until
translators backfill (flagged as a follow-up).
Out of scope (deferred per user instruction): goals 3 and 4 from the
original spec β creator-prefs fire policy verification and email
preference parity audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: honour project-level Ignore for task creator and company owner
Closes goals 3 & 4 of the notification refactor. Two parallel bypasses
in handleNotification.HandleBothNotification let task creators and
company owners through the project-level watcher filter (the Ignore /
All Activity / Participating setting in the List of Watcher panel):
- Task branch (taskId path): Task_Leader was Set-union'd into the
recipient list AFTER `projectData.watchers` had already filtered out
any user set to "ignore". So a creator who had set the project to
Ignore still ended up in `assigneeUsers`, and the downstream
per-event preference check (NOTIFICATIONS_SETTINGS β a separate
preference layer that defaults email=true) emitted both an in-app
and an email notification. Now Task_Leader is included only if their
project-watcher setting is not "ignore".
- Project branch (type === 'project'): same pattern β companyOwnerId
was union'd in unconditionally, bypassing the watcher filter.
Honours "ignore" too.
This explains the goal-4 symptom of "I'm getting emails for events I
disabled": the disablement was set at the project-watcher layer, but
the bypass routed the recipient straight to the per-event layer where
email was still on.
Other recipient paths are untouched. Default behaviour for users who
have not chosen "ignore" is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: per-company screenshot retention policy + nightly cleanup cron
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>
* refactor: comment out unused companyId injection in SettingScreenshotRetention component
* feat(setup): one-command developer setup via `npm run setup`
Adds an additive setup orchestrator that installs deps, builds the wizard,
bootstraps .env, starts backend + frontend, completes the installation wizard
headlessly, creates a default admin account, and opens the login page β all
from a single command. Non-technical contributors can go from `git clone` to
a working login screen with no manual steps.
New
- scripts/dev.js β orchestrator with HTTP-based wizard auto-completion,
MongoDB probe with retry, defensive .env patching, credentials banner.
- nodemon.json β explicit watch list (server-side dirs / .js only) so wizard
writes to installationSteps.json no longer restart the backend mid-request
(root cause of the "wizard reloads on MongoDB step" bug).
- package.json β `setup`, `dev`, `setup:reset` scripts + nodemon devDep.
Wizard improvements (back-compat preserving)
- Modules/CheckInstallStep/controller.js: `isDoItLater` support added for
Firebase (step 4) and SMTP (step 6), mirroring the existing AI (step 5)
skip pattern. Both steps remain mandatory unless the caller opts in.
- Defensive APIURL fallback at module-load (was crashing the backend on
fresh clones if .env hadn't loaded yet β TypeError on .substring of
undefined).
.env.example
- SERVICE_FILE corrected from "../firebase-adminsdk.json" to
"./firebase-adminsdk.json" (the prior default tripped the BUG-036
path-traversal guard and blocked the Firebase wizard step).
- Quick-start comment block at the top.
Nothing existing changes
- `npm start`, `npm run nodemon`, `npm run basic-install`, the interactive
wizard, and the documented manual setup paths are all untouched.
- New scripts never run automatically; users must invoke them explicitly.
- Fallback chain at every failure point (MongoDB unreachable / auto-setup
error / --manual flag) opens the interactive wizard UI so the user is
never left in an unrecoverable state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(screenshot-retention): security + correctness pass on PR #157
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>
* feat(security): P0 + partial P1 hardening, CI pipeline, multer v2
Closes the critical security and dependency gaps surfaced in the
codebase audit. All 20 unit tests pass; no behavior change for normal
request paths.
Backend security
- Sanitize regex search inputs across 7 controllers (AI,
AdvancedGlobalFilter, Comments, MediaFiles, Project filter,
notification-count, trackerDownload) via new utils/escapeRegex.js.
Blocks NoSQL regex injection / ReDoS / cross-tenant name enumeration.
- Add helmet + global express-rate-limit in index.js. CSP disabled to
preserve the Vue inline-script setup; secure / sameSite already
env-gated correctly. trust-proxy set to 'loopback' to silence the
ERR_ERL_UNEXPECTED_X_FORWARDED_FOR warning behind a same-host
reverse proxy.
- Harden all 4 multer call sites via new utils/uploadConfig.js
(DEFAULT_LIMITS, safeFileFilter blocking executable extensions,
safeRelativePath for traversal protection). bucket.helper.js
storage destination now validates path before writing.
- New requireCompanyAud middleware (Config/jwt.js) enforces the JWT
`aud` claim against any companyId in body / params / query /
header on the ~50 verifyJWTTokenV2-only routes. Non-ObjectId
values (e.g. USER_PROFILES global bucket) pass through.
Dependencies
- Remove aws-sdk v2 from package.json (EOL Sept 2025); only @aws-sdk
v3 was actually imported.
- Bump multer 1.4.5-lts.1 -> 2.1.1 to clear known 1.x CVEs.
Schema strictness (P1-SEC-11)
- utils/mongo-handler/createSchema.js: flipped 7 core schemas to
`strict: true` (tasks, comments, timesheet, history, adminDetail,
subscriptionPlan, globalCustomFields). Kept `strict: false` on 7
intentionally-dynamic schemas (notification counters, Chargebee
webhook mirrors, custom-field definitions, plan-feature maps) with
inline comments explaining why.
CI / DevOps
- .github/workflows/main.yml: added a `validate` job (npm test, npm
audit advisory, frontend install + build) that the deploy job now
`needs:`. Previously deploys ran with zero validation.
Diagnostics
- modules/storage/wasabi/controller.js: replaced 6 generic
`Error while upload file: ${error}` rejects with a
formatS3UploadError helper that logs the AWS error Code, HTTP
status, bucket, key, and requestId.
Documented for follow-up
- Auth/controller.js: TODO comments at both res.cookie sites
explaining why httpOnly stays false until the frontend stops
reading the cookie via js-cookie (P1-SEC-09 deferred).
Pre-existing fixes pulled in
- tests/utils/imageGuard.test.js: corrected env var names so the
suite goes from 19/20 to 20/20 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(safeServiceFile): reject Windows absolute paths on any host
The CI pipeline added in the same PR is the first thing to run
tests on Ubuntu. `path.isAbsolute('C:\Windows\...')` returns
false on Linux, so the safeServiceFile absolute-path guard fell
through and the request hit the `.json` extension check instead.
The test in `tests/utils/safeServiceFile.test.js:46` expects the
"relative" error in both shapes and was passing on Windows hosts
by accident.
Use `path.win32.isAbsolute(...)` alongside the platform-bound
`path.isAbsolute(...)` so both POSIX (`/etc/hosts`) and Windows
(`C:\Windows\...`) absolute paths are rejected regardless of
which OS the process runs on. This is also a defensive improvement
to BUG-036: a Linux deployment can no longer be coaxed into
re-resolving a Windows-shaped path inside the project root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Update main.yml
* Update main.yml
* feat(ai-project-generator): one-shot AI project bootstrap with PDF brief + SSE progress
Adds a parallel "Create with AI" path to the existing manual project wizard.
The user describes their project in natural language (optionally attaches a
PDF/DOCX/MD/TXT brief and chooses public/private workspace + a target task
count), the LLM returns a plan (project metadata + folders β sprints β
tasks with rich descriptions), the user reviews/edits names, and on
"Create everything" the orchestrator commits the whole hierarchy via the
same write paths the manual flow uses β with SSE progress events and
rollback on partial failure.
== Backend ==
- New module Modules/AIProjectGenerator with:
* llmProvider/ β env-selected adapter (openai | anthropic). Reads
AI_API_KEY/AI_MODEL (existing) for OpenAI and ANTHROPIC_API_KEY/
ANTHROPIC_MODEL (new) for Anthropic, picked via LLM_PROVIDER.
* briefExtractor.js β multer v2 disk-buffer + pdf-parse + mammoth, 10MB
cap, mime allow-list, control-char strip, 100k char truncation.
* schemaValidator.js β zod PlanSchema with strict task/status/folder
rules; sanitizes member ids against the active company roster.
* promptTemplates.js β system + user + repair prompts. Forces 4-8
tasks per sprint, domain-specific status names, full lifecycle coverage.
* orchestrator.js β sequential project β folders β sprints β bulk tasks.
Reserves a taskKey range atomically, emits SSE per step, soft-rollbacks
in reverse on any failure (tasks β sprints β folders β project +
projectCount decrement).
* sseEmitter.js β heartbeat-equipped SSE channel keyed by random jobId.
* Endpoints: /api/v1/ai/project/{upload-brief,plan,clarify,execute}
+ unauthenticated /api/v1/ai-progress/:jobId (jobId is a bearer
capability β same pattern as /api/v1/generatePrompt/events).
- Config/setMiddleware.js: protected new auth paths via
verifyJWTTokenWithCRoute; SSE endpoint deliberately omitted.
- index.js: register the new module after Modules/AI.
- .env.example: LLM_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
LLM_MAX_TOKENS_PLAN, LLM_MAX_TOKENS_CLARIFY.
- package.json: +@anthropic-ai/sdk, +pdf-parse, +mammoth, +zod.
== Frontend ==
- New AiProjectCreator.vue (organism) β 3-step sidebar:
1. Describe: textarea (20-char minimum), public/private workspace toggle
(mirrors manual ProjectWorkspace step), target-task-count slider,
PDF/DOCX/TXT/MD upload, inline clarification Q&A.
2. Review plan: project icon/code/description preview + inline-editable
names at every level (project / folder / sprint / task), expandable
task descriptions.
3. Execute: live SSE progress UI (project β folders β sprints β tasks)
with rollback-aware error state and "Open project" CTA on complete.
Close affordance is a close icon (was a Cancel button) β disabled
while uploading/loading; the sidebar can't be dismissed during the
execute phase to prevent orphaned in-flight jobs.
- New aiProjectGenerator.js composable wraps the four endpoints +
EventSource subscription.
- Projects.vue / ProjectListComponent.vue / ProjectListing.vue add the
"β¨ Create with AI" button beside "+ New Project", gated on
currentCompany.planFeature.aiPermission.
- env.js: 5 new endpoint constants.
== Highlights from QA pass ==
- projectIcon is persisted in the canonical {type:'color', data:'#hex'}
shape Item.vue expects, so the AI-bootstrapped project's color/initial
pill renders in the sidebar (was an empty box).
- Multer rejection (LIMIT_FILE_SIZE, unsupported mime, etc.) is caught
before Express's default handler so users see "File is too large.
Maximum allowed size is 10 MB." instead of "Request failed with status
code 500".
- The user's explicit public/private choice in step 1 is forced onto the
plan server-side at /plan, /clarify and /execute β it always wins over
whatever the LLM emitted.
- Plan-feature quota (projectCount.*) is incremented like the manual
flow but the AI path intentionally skips checkProjectPlan's hard cap
so this feature remains usable on plans that limit project counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(main.yml): map VUE_APP_* secrets into the frontend build step
The CI frontend build was running with no .env present (gitignored, not
materialised on the runner) so every `process.env.VUE_APP_*` reference
was inlined as `undefined` in the bundle. Map the Firebase keys, storage
config, support-routing ids, and OAuth feature flags as repository
secrets in the Build frontend step so the validation build matches what
production actually ships.
Also bumps Node to 22, switches to npm ci, adds concurrency cancellation
for staging deploys, and tightens the workflow trigger list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(env): update AI model to gpt-4o-mini in .env.example
* refactor(projects): split Projects.vue and Task.vue mega-components (#167)
Projects.vue: 2,737 -> 1,365 lines (50% reduction). Task.vue: 1,015 -> 540 lines (47% reduction).
- 9 composables extracted (useProjectCalendar, useProjectAvatar, useEmbedViews,
useProjectLifecycle, useProjectAssignee, useProjectTour, useProjectSearch,
useProjectRules, useProjectNameEdit)
- 5 sub-components extracted (ProjectActionsBar, ProjectFiltersToolbar,
ProjectSidebars, ProjectBottomModals, ProjectEmptyState)
- 7 view components (ListView, Comments, ActivityLog, WorkloadView, BoardView,
ProjectDetail, TableView, EmbedViewItem) converted to defineAsyncComponent
so each tab pulls its own chunk on demand
- 2 task composables (useTaskMutations, useTaskActions) and 1 sub-component
(TaskQuickMenu) extracted; duplicate mobile/desktop quick-menu markup deduped
Bundle (npm run build): main project chunk 2,621,285 -> 2,047,548 bytes
(-22%, -560 KB). New lazy chunks: project-list-view 381 KB, project-detail
117 KB, project-kanban 42 KB, project-table-view 28 KB, project-workload
17 KB, embed-view 3 KB, project-activity-log 0.5 KB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(backend): split task/logtime/milestone/auth mega-files (#164)
Splits four oversized source files into focused sub-modules so no file in
the affected modules exceeds ~640 lines (issue target: ~800). Public API
is preserved: each original module path keeps re-exporting every symbol
it used to, so routes.js and downstream consumers are untouched.
- Modules/Tasks/helpers/task_class_Mongo.js (3,129 -> 27 lines)
Mixin pattern under taskMongo/: create, updateBasic, updateAssignment,
updateMeβ¦
joshishiv4
added a commit
that referenced
this pull request
Jun 19, 2026
* fix(BUG-042): drop `moment` from backend; standardise on luxon
CLAUDE.md says the project standardised on Luxon. The backend still
imported `moment` in five files for trivial date formatting; this PR
removes those usages and drops `moment` from the root package.json.
Frontend `moment` usage (hundreds of call sites across Timesheet /
TimeLog / composables) is NOT touched here β that's a much larger
migration scoped separately. The frontend's own `package.json` keeps
its `moment` entry so the Vue build doesn't break.
New helper:
* `utils/dateHelpers.js` exposes
- `formatDate(input, fmt)` β accepts JS Date, millis, ISO string,
or `{seconds}` shape, and translates moment-style format tokens
(YYYY/MM/DD/HH/mm/ss/A/MMM/ddd) to luxon-style under the hood
so existing callers don't have to change their format strings.
- `formatNotificationDate(input)` β exact replacement for the two
`moment.calendar()` sites in `Modules/notification/sendEmail/`,
which both used the same format string for every branch (so the
relative-time wrapper was a no-op). Preserves the original
output, including the pre-existing `HH:MM` token quirk.
Migrations:
* `Modules/logTime/controllerV2.js` β single `.format("YYYY-MM-DD")`
call β `formatDate(date, 'yyyy-LL-dd')`.
* `Modules/notification/sendEmail/controller.js` and
`Modules/notification/sendEmail/controllerV2.js` β
`moment().calendar(...)` β `formatNotificationDate(...)`.
* `Modules/tasks/helpers/helper.js` and
`Modules/tasks/helpers/mongo_helper.js` β `changeDateFormat`
delegates to `formatDate` (which keeps the moment-style API its
callers pass).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-043): wrap clickable <span>/<img> affordances in real <button>
Two affordances dispatched click handlers on non-semantic elements
(no role, no aria-label, no keyboard focus, no screen-reader
announcement):
* `frontend/src/components/atom/Modal/Modal.vue` β the modal close
icon was a bare `<img @click="closeModal()">`. Wrap in
`<button type="button" :aria-label="$t('Projects.close') || 'Close'">`
with the icon inside as a presentational `<img alt="">`.
* `frontend/src/components/atom/Attachments/Attachments.vue` β the
"Download All" affordance was a `<span @click="downloadAllImages()">`.
Replace with `<button type="button" class="download-all-btn ...">`.
Both components' stylesheets gain a tiny rule to strip native button
chrome (so the visual result matches the old elements) but preserve
`:focus-visible` so keyboard users see the focused state.
Note: a couple of nearby affordances (Attachments' "See All" `<div>`
and the help-icon popover) have the same anti-pattern but are out of
scope here β the audit specifically flagged the `<span>` and `<img>`
cases above.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-044): give Modal role=dialog, aria-modal, and focus trap
The Modal atom rendered a bare `<div class="modal">` β no role,
no aria-modal, no aria-labelledby. Screen readers couldn't announce
the modal as a dialog and focus could leave the modal via Tab and
reach background controls.
Changes to `frontend/src/components/atom/Modal/Modal.vue`:
* Template:
- Root `<div class="modal">` gains `role="dialog"`,
`aria-modal="true"`, `:aria-labelledby="titleId"`, and
`tabindex="-1"` (so focus can land on the dialog itself when no
children are focusable).
- The title `<span>` now carries the `:id="titleId"` that
aria-labelledby points at.
- `@keydown.tab="handleTabKeydown"` traps Tab; `@keydown.esc.stop`
closes the modal on Escape.
* Script:
- `modalRef` template ref + `titleId` computed.
- `handleTabKeydown` cycles focus between the first and last
focusable descendants (queries the standard focusable selector
list, skipping aria-hidden and offscreen elements).
- `activateFocusTrap` captures the previously-focused element and
moves focus to the first focusable inside the modal (or the
dialog itself if there are none). Called on mount-if-open and
when `modelValue` flips to true.
- `deactivateFocusTrap` restores focus to the captured element on
close / unmount.
No new dependency: the trap is ~40 lines of inline logic; we don't
need `focus-trap` for one component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-045): wire up the jest test suite
`npm test` was a placeholder (`echo "Error: no test specified" && exit 1`)
even though jest was already installed as a devDependency. This PR
wires it up as a working bootstrap suite that future fixes can grow
into.
Changes:
* `package.json` β
- `test` now runs `jest`.
- new `test:watch` (development) and `test:naming` (the pre-existing
structural-audit test, kept off the default run because it flags
legacy naming inconsistencies tracked separately).
* `jest.config.js` β points jest at `tests/`, ignores
node_modules/frontend/installation/time-tracker-app/.claude and the
naming-conventions audit, and runs in `node` environment.
* `tests/smoke.test.js` β minimal guarantee the harness is alive
(always runs).
* `tests/utils/dateHelpers.test.js`, `tests/utils/safeServiceFile.test.js`,
`tests/utils/imageGuard.test.js` β behaviour tests for the helpers
introduced earlier in this audit (BUG-042, BUG-036, BUG-023). Each
suite is wrapped in a graceful skip when its target module isn't on
the current branch, so this PR lands cleanly today and the tests
light up automatically once the corresponding helper PRs merge to
staging.
`npm test` exits 0 β 3 passed (smoke) + 16 skipped (helpers pending
merge). After BUG-023/036/042 merge, all 19 should pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-046): let Mongoose manage history timestamps
`Modules/tasks/helpers/{helper,mongo_helper}.js`'s `HandleHistory`
wrote `createdAt` / `updatedAt` manually using `DateTime.utc().ts`,
which is a numeric millisecond value β not a BSON Date. Existing
documents ended up with inconsistent shapes (number vs Date) depending
on which code path created them.
Every other schema in `utils/mongo-handler/createSchema.js` is built
with `{ timestamps: true }` so Mongoose owns the timestamps. The
history schema alone was missing that option.
Changes:
* `utils/mongo-handler/createSchema.js` β add
`{ strict: false, timestamps: true }` to `historySchema`.
* `Modules/tasks/helpers/mongo_helper.js` (HandleHistory) and
`Modules/tasks/helpers/helper.js` (HandleHistory) β drop the manual
`createdAt: utcDateTime.ts, updatedAt: utcDateTime.ts` lines.
Mongoose populates both as BSON Dates on `.save()`.
* `utils/mongo-handler/schema.js` β keep `createdAt`/`updatedAt`
declared on the history shape so callers that read them still work,
but drop `required: true`. Mongoose sets them on `.save()`; making
them required on updates would reject legacy docs that never had
them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(BUG-047): ARIA semantics + keyboard nav for CustomDropDown
`frontend/src/components/molecules/DropDown/CustomDropDown.vue` is the
shared custom dropdown atom used across the app. Pre-fix:
* The trigger `<div @click="buttonClick()">` had no role,
no aria-haspopup, no aria-expanded, no tabindex β keyboard users
couldn't reach or open it.
* The floating panel had no role="listbox" β screen readers couldn't
identify it as a menu.
* The mobile-view close affordance was a bare clickable `<img>`.
Changes:
* Trigger gains role="button", tabindex="0", :aria-haspopup="'listbox'",
:aria-expanded (bound to open state), :aria-controls (panel id).
* Trigger handles keyboard:
- Enter / Space β open/close (mirrors click).
- Escape β close.
- ArrowDown β open AND move focus to the first focusable child
inside the options slot. (We can't enforce role="option" on
user-provided slot content, but moving focus there gets keyboard
users navigating immediately.)
* Floating panel gains role="listbox" and an @keydown.esc handler.
* Mobile close `<img>` wrapped in a real `<button type="button">`
with aria-label="Close" and a style rule (`.dropdown-close-btn`)
that strips native chrome but preserves :focus-visible.
* New helpers: `closeDropdown()` and `openAndFocusFirstOption()`.
Note: the sibling `DropDown.vue` and `MobileDropDown.vue` follow the
same anti-pattern but were not flagged by the audit; they'd benefit
from a similar sweep in a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Done - Test Case
Done - Test Case
* Add Gitigonre
Add Gitigonre
* Rename the folder Auth
Rename the folder Auth
* Rename Folder
Rename Folder
* Chnage the name
Chnage the name
* Folder Rename
Folder Rename
* remove dist
remove dist
* Create yml
Create yml
* Update main.yml
* Update main.yml
* Update main.yml
* Basic Setup
Basic Setup
* fix: repair broken advisory URL in SECURITY.md (#44)
The advisory link was split across two lines and wrapped in a code span,
rendering as malformed text rather than a clickable link. Joined the URL
and used proper markdown link syntax. Pairs with enabling Private
Vulnerability Reporting in repo settings so external reporters can
actually submit advisories.
Closes #44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: refresh correct lastRequest field on connection reuse (#42)
updateConnectionRecord was writing to lastRequset (typo) while the
idle-cleanup loop in startInterval reads lastRequest. Active company
databases therefore kept the timestamp frozen at createdAt and were
eligible for termination after the 30-minute window despite continuous
use. Fixed the property name so the update path and the cleanup path
reference the same field.
Closes #42
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: remove duplicate CheckInstallStep mount and .env hot-reload (#41)
Two related sources of duplicate-handler accumulation in the bootstrap:
1. CheckInstallStep was mounted twice: once inside
initializeControllers() and once unconditionally at the top level.
The top-level mount has to stay because the install wizard must be
reachable before MONGODB_URL is configured. Removed the inner mount
so each startup registers it exactly once.
2. fs.watchFile on .env called initializeControllers() on every save,
which re-ran .init(app) for ~60 route modules onto the same Express
app instance (Express has no clean route un-registration) and spun
another setInterval inside startInterval(). Removed the watcher;
nodemon already restarts on file changes in dev, and production
env changes require a process restart anyway.
After this change initializeControllers() is only invoked once, from
the MONGODB_URL startup gate.
Closes #41
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: preserve dataType and options across JWT refresh retry (#38)
apiRequest and apiRequestWithoutCompnay reconstruct the call after a
token refresh using only (type, endPoint, data), dropping dataType
and options. Any retry of a multipart upload then runs through the
JSON axios instance instead of the form-data instance, and any
caller-supplied abort signal or per-request option is lost. Forwarded
dataType and options into both retry calls so the replay matches the
original.
Closes #38
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: clean up close_click IPC listener after each notification (#39)
sendNotification() registered a new ipcMain.on('close_click', ...)
handler on every screenshot capture and never removed it, so the
process accumulated one global IPC listener per screenshot and a
single close click eventually fired N stale handlers, each trying to
close an already-destroyed BrowserWindow reference.
Switched the registration to ipcMain.once so the happy path
(user clicks close) auto-removes the listener. For the auto-timeout
path (window closes after 10s without a click) the handler stays
registered and would later steal a future notification's close
event, so also remove it explicitly when the window emits 'closed'.
The timeout id is now tracked and cleared on the same hook so the
timer can't fire against a destroyed window. The window reference
is captured in a local so each listener targets its own window even
if a subsequent sendNotification() reassigns the module-level
screenshotNotificationWindow before this listener fires.
Closes #39
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: allow editing the "Created by" user on a task
The task sidebar Details panel previously showed Created by (the
Task_Leader field) as a read-only user β set once at creation and
never changeable. Add an inline picker mirroring the existing
Assignee field so the creator can be reassigned.
Changes:
- Backend: new updateTaskLeader action on task_class_Mongo. Validates
the new Task_Leader, writes via $set, emits the same
socketEmitter.emit('update', { module: 'task', updatedFields }) used
by updateAssignee, and logs a TaskLeader_Changed history entry via
HandleHistory. Includes the isUpdateTask:false side-effects-only
branch for parity with updateStatus / updatePriority.
- Frontend store (TaskOperations): new updateTaskLeader action that
optimistically commits the new Task_Leader into the projectData
Vuex store, then PATCHes /api/v2/tasks with action 'updateTaskLeader'.
- TaskDetailRightSide.vue: replaced the read-only Created-by block
with an Assignee picker (single-select) for users holding the
task.task_assignee + task.task_list permissions, preserving the
original read-only display as the fallback for users without those
permissions. Wired @selected to a new updateTaskLeader() handler
with the same toast / error pattern as updateAssignee.
- i18n: added Toast.Created_by_updated_successfully and
Toast.Created_by_not_updated to the English locale. The other 10
locales fall back to English via vue-i18n until translators
backfill β flagged as a follow-up.
Permission policy: reuses task.task_assignee. Anyone allowed to
change the Assignee can also change Created by. No new permission
key, no role-permission migration. Backend has no per-field gate,
consistent with the existing Assignee / Status / Priority update
paths which all trust the frontend permission gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: split header notifications into Unread and Archive views
Previously the notification bell fetched every notification the user
had ever received (read or unread) in a single combined list, making
it hard to focus on actionable items. This change splits the dropdown
into two views β Unread (default on every open) and Archive β gated
by a single toggle button in the dropdown head.
Changes:
- Backend: GET /api/v1/app-notification/notification accepts a new
optional `filter` query param ('unread' | 'archived'). 'unread' adds
`notSeen: { $in: [userId] }` to the aggregation match; 'archived'
adds `notSeen: { $nin: [userId] }`. Default is 'unread' to keep the
bell focused on actionable items. Refactored the match clauses into
a `baseMatch` array so the filter is appended cleanly; query shape
and pagination are otherwise unchanged.
- Header.vue: added a `notificationFilter` ref ('unread' by default),
a "View Archive" / "View Unread" toggle in the dropdown head, a
`switchNotificationFilter()` handler that resets paging state and
refetches, and an `openNotificationsDropdown()` helper that the
bell click handlers now use so each fresh dropdown open lands in
Unread. `markRead()` and `markAllRead()` clear the read items from
the local list while the Unread filter is active so the dropdown
reflects the filter without an extra refetch. The "Mark all as
read" button visibility now keys off `notifications.length` (the
list is already filtered to unread on this view) rather than the
server-side `totalNotification` counter, which can lag and falsely
hide the button.
- i18n: added Header.View_Archive / Header.View_Unread to the English
locale; other locales fall back to English via vue-i18n until
translators backfill (flagged as a follow-up).
Out of scope (deferred per user instruction): goals 3 and 4 from the
original spec β creator-prefs fire policy verification and email
preference parity audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: honour project-level Ignore for task creator and company owner
Closes goals 3 & 4 of the notification refactor. Two parallel bypasses
in handleNotification.HandleBothNotification let task creators and
company owners through the project-level watcher filter (the Ignore /
All Activity / Participating setting in the List of Watcher panel):
- Task branch (taskId path): Task_Leader was Set-union'd into the
recipient list AFTER `projectData.watchers` had already filtered out
any user set to "ignore". So a creator who had set the project to
Ignore still ended up in `assigneeUsers`, and the downstream
per-event preference check (NOTIFICATIONS_SETTINGS β a separate
preference layer that defaults email=true) emitted both an in-app
and an email notification. Now Task_Leader is included only if their
project-watcher setting is not "ignore".
- Project branch (type === 'project'): same pattern β companyOwnerId
was union'd in unconditionally, bypassing the watcher filter.
Honours "ignore" too.
This explains the goal-4 symptom of "I'm getting emails for events I
disabled": the disablement was set at the project-watcher layer, but
the bypass routed the recipient straight to the per-event layer where
email was still on.
Other recipient paths are untouched. Default behaviour for users who
have not chosen "ignore" is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: per-company screenshot retention policy + nightly cleanup cron
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>
* refactor: comment out unused companyId injection in SettingScreenshotRetention component
* feat(setup): one-command developer setup via `npm run setup`
Adds an additive setup orchestrator that installs deps, builds the wizard,
bootstraps .env, starts backend + frontend, completes the installation wizard
headlessly, creates a default admin account, and opens the login page β all
from a single command. Non-technical contributors can go from `git clone` to
a working login screen with no manual steps.
New
- scripts/dev.js β orchestrator with HTTP-based wizard auto-completion,
MongoDB probe with retry, defensive .env patching, credentials banner.
- nodemon.json β explicit watch list (server-side dirs / .js only) so wizard
writes to installationSteps.json no longer restart the backend mid-request
(root cause of the "wizard reloads on MongoDB step" bug).
- package.json β `setup`, `dev`, `setup:reset` scripts + nodemon devDep.
Wizard improvements (back-compat preserving)
- Modules/CheckInstallStep/controller.js: `isDoItLater` support added for
Firebase (step 4) and SMTP (step 6), mirroring the existing AI (step 5)
skip pattern. Both steps remain mandatory unless the caller opts in.
- Defensive APIURL fallback at module-load (was crashing the backend on
fresh clones if .env hadn't loaded yet β TypeError on .substring of
undefined).
.env.example
- SERVICE_FILE corrected from "../firebase-adminsdk.json" to
"./firebase-adminsdk.json" (the prior default tripped the BUG-036
path-traversal guard and blocked the Firebase wizard step).
- Quick-start comment block at the top.
Nothing existing changes
- `npm start`, `npm run nodemon`, `npm run basic-install`, the interactive
wizard, and the documented manual setup paths are all untouched.
- New scripts never run automatically; users must invoke them explicitly.
- Fallback chain at every failure point (MongoDB unreachable / auto-setup
error / --manual flag) opens the interactive wizard UI so the user is
never left in an unrecoverable state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(screenshot-retention): security + correctness pass on PR #157
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>
* feat(security): P0 + partial P1 hardening, CI pipeline, multer v2
Closes the critical security and dependency gaps surfaced in the
codebase audit. All 20 unit tests pass; no behavior change for normal
request paths.
Backend security
- Sanitize regex search inputs across 7 controllers (AI,
AdvancedGlobalFilter, Comments, MediaFiles, Project filter,
notification-count, trackerDownload) via new utils/escapeRegex.js.
Blocks NoSQL regex injection / ReDoS / cross-tenant name enumeration.
- Add helmet + global express-rate-limit in index.js. CSP disabled to
preserve the Vue inline-script setup; secure / sameSite already
env-gated correctly. trust-proxy set to 'loopback' to silence the
ERR_ERL_UNEXPECTED_X_FORWARDED_FOR warning behind a same-host
reverse proxy.
- Harden all 4 multer call sites via new utils/uploadConfig.js
(DEFAULT_LIMITS, safeFileFilter blocking executable extensions,
safeRelativePath for traversal protection). bucket.helper.js
storage destination now validates path before writing.
- New requireCompanyAud middleware (Config/jwt.js) enforces the JWT
`aud` claim against any companyId in body / params / query /
header on the ~50 verifyJWTTokenV2-only routes. Non-ObjectId
values (e.g. USER_PROFILES global bucket) pass through.
Dependencies
- Remove aws-sdk v2 from package.json (EOL Sept 2025); only @aws-sdk
v3 was actually imported.
- Bump multer 1.4.5-lts.1 -> 2.1.1 to clear known 1.x CVEs.
Schema strictness (P1-SEC-11)
- utils/mongo-handler/createSchema.js: flipped 7 core schemas to
`strict: true` (tasks, comments, timesheet, history, adminDetail,
subscriptionPlan, globalCustomFields). Kept `strict: false` on 7
intentionally-dynamic schemas (notification counters, Chargebee
webhook mirrors, custom-field definitions, plan-feature maps) with
inline comments explaining why.
CI / DevOps
- .github/workflows/main.yml: added a `validate` job (npm test, npm
audit advisory, frontend install + build) that the deploy job now
`needs:`. Previously deploys ran with zero validation.
Diagnostics
- modules/storage/wasabi/controller.js: replaced 6 generic
`Error while upload file: ${error}` rejects with a
formatS3UploadError helper that logs the AWS error Code, HTTP
status, bucket, key, and requestId.
Documented for follow-up
- Auth/controller.js: TODO comments at both res.cookie sites
explaining why httpOnly stays false until the frontend stops
reading the cookie via js-cookie (P1-SEC-09 deferred).
Pre-existing fixes pulled in
- tests/utils/imageGuard.test.js: corrected env var names so the
suite goes from 19/20 to 20/20 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(safeServiceFile): reject Windows absolute paths on any host
The CI pipeline added in the same PR is the first thing to run
tests on Ubuntu. `path.isAbsolute('C:\Windows\...')` returns
false on Linux, so the safeServiceFile absolute-path guard fell
through and the request hit the `.json` extension check instead.
The test in `tests/utils/safeServiceFile.test.js:46` expects the
"relative" error in both shapes and was passing on Windows hosts
by accident.
Use `path.win32.isAbsolute(...)` alongside the platform-bound
`path.isAbsolute(...)` so both POSIX (`/etc/hosts`) and Windows
(`C:\Windows\...`) absolute paths are rejected regardless of
which OS the process runs on. This is also a defensive improvement
to BUG-036: a Linux deployment can no longer be coaxed into
re-resolving a Windows-shaped path inside the project root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Update main.yml
* Update main.yml
* feat(ai-project-generator): one-shot AI project bootstrap with PDF brief + SSE progress
Adds a parallel "Create with AI" path to the existing manual project wizard.
The user describes their project in natural language (optionally attaches a
PDF/DOCX/MD/TXT brief and chooses public/private workspace + a target task
count), the LLM returns a plan (project metadata + folders β sprints β
tasks with rich descriptions), the user reviews/edits names, and on
"Create everything" the orchestrator commits the whole hierarchy via the
same write paths the manual flow uses β with SSE progress events and
rollback on partial failure.
== Backend ==
- New module Modules/AIProjectGenerator with:
* llmProvider/ β env-selected adapter (openai | anthropic). Reads
AI_API_KEY/AI_MODEL (existing) for OpenAI and ANTHROPIC_API_KEY/
ANTHROPIC_MODEL (new) for Anthropic, picked via LLM_PROVIDER.
* briefExtractor.js β multer v2 disk-buffer + pdf-parse + mammoth, 10MB
cap, mime allow-list, control-char strip, 100k char truncation.
* schemaValidator.js β zod PlanSchema with strict task/status/folder
rules; sanitizes member ids against the active company roster.
* promptTemplates.js β system + user + repair prompts. Forces 4-8
tasks per sprint, domain-specific status names, full lifecycle coverage.
* orchestrator.js β sequential project β folders β sprints β bulk tasks.
Reserves a taskKey range atomically, emits SSE per step, soft-rollbacks
in reverse on any failure (tasks β sprints β folders β project +
projectCount decrement).
* sseEmitter.js β heartbeat-equipped SSE channel keyed by random jobId.
* Endpoints: /api/v1/ai/project/{upload-brief,plan,clarify,execute}
+ unauthenticated /api/v1/ai-progress/:jobId (jobId is a bearer
capability β same pattern as /api/v1/generatePrompt/events).
- Config/setMiddleware.js: protected new auth paths via
verifyJWTTokenWithCRoute; SSE endpoint deliberately omitted.
- index.js: register the new module after Modules/AI.
- .env.example: LLM_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
LLM_MAX_TOKENS_PLAN, LLM_MAX_TOKENS_CLARIFY.
- package.json: +@anthropic-ai/sdk, +pdf-parse, +mammoth, +zod.
== Frontend ==
- New AiProjectCreator.vue (organism) β 3-step sidebar:
1. Describe: textarea (20-char minimum), public/private workspace toggle
(mirrors manual ProjectWorkspace step), target-task-count slider,
PDF/DOCX/TXT/MD upload, inline clarification Q&A.
2. Review plan: project icon/code/description preview + inline-editable
names at every level (project / folder / sprint / task), expandable
task descriptions.
3. Execute: live SSE progress UI (project β folders β sprints β tasks)
with rollback-aware error state and "Open project" CTA on complete.
Close affordance is a close icon (was a Cancel button) β disabled
while uploading/loading; the sidebar can't be dismissed during the
execute phase to prevent orphaned in-flight jobs.
- New aiProjectGenerator.js composable wraps the four endpoints +
EventSource subscription.
- Projects.vue / ProjectListComponent.vue / ProjectListing.vue add the
"β¨ Create with AI" button beside "+ New Project", gated on
currentCompany.planFeature.aiPermission.
- env.js: 5 new endpoint constants.
== Highlights from QA pass ==
- projectIcon is persisted in the canonical {type:'color', data:'#hex'}
shape Item.vue expects, so the AI-bootstrapped project's color/initial
pill renders in the sidebar (was an empty box).
- Multer rejection (LIMIT_FILE_SIZE, unsupported mime, etc.) is caught
before Express's default handler so users see "File is too large.
Maximum allowed size is 10 MB." instead of "Request failed with status
code 500".
- The user's explicit public/private choice in step 1 is forced onto the
plan server-side at /plan, /clarify and /execute β it always wins over
whatever the LLM emitted.
- Plan-feature quota (projectCount.*) is incremented like the manual
flow but the AI path intentionally skips checkProjectPlan's hard cap
so this feature remains usable on plans that limit project counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(main.yml): map VUE_APP_* secrets into the frontend build step
The CI frontend build was running with no .env present (gitignored, not
materialised on the runner) so every `process.env.VUE_APP_*` reference
was inlined as `undefined` in the bundle. Map the Firebase keys, storage
config, support-routing ids, and OAuth feature flags as repository
secrets in the Build frontend step so the validation build matches what
production actually ships.
Also bumps Node to 22, switches to npm ci, adds concurrency cancellation
for staging deploys, and tightens the workflow trigger list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(env): update AI model to gpt-4o-mini in .env.example
* refactor(projects): split Projects.vue and Task.vue mega-components (#167)
Projects.vue: 2,737 -> 1,365 lines (50% reduction). Task.vue: 1,015 -> 540 lines (47% reduction).
- 9 composables extracted (useProjectCalendar, useProjectAvatar, useEmbedViews,
useProjectLifecycle, useProjectAssignee, useProjectTour, useProjectSearch,
useProjectRules, useProjectNameEdit)
- 5 sub-components extracted (ProjectActionsBar, ProjectFiltersToolbar,
ProjectSidebars, ProjectBottomModals, ProjectEmptyState)
- 7 view components (ListView, Comments, ActivityLog, WorkloadView, BoardView,
ProjectDetail, TableView, EmbedViewItem) converted to defineAsyncComponent
so each tab pulls its own chunk on demand
- 2 task composables (useTaskMutations, useTaskActions) and 1 sub-component
(TaskQuickMenu) extracted; duplicate mobile/desktop quick-menu markup deduped
Bundle (npm run build): main project chunk 2,621,285 -> 2,047,548 bytes
(-22%, -560 KB). New lazy chunks: project-list-view 381 KB, project-detail
117 KB, project-kanban 42 KB, project-table-view 28 KB, project-workload
17 KB, embed-view 3 KB, project-activity-log 0.5 KB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(backend): split task/logtime/milestone/auth mega-files (#164)
Splits four oversized source files into focused sub-modules so no file in
the affected modules exceeds ~640 lines (issue target: ~800). Public API
is preserved: each original module path keeps re-exporting every symbol
it used to, so routes.js and downstream consumers are untouched.
- Modules/Tasks/helpers/task_class_Mongo.js (3,129 -> 27 lines)
Mixin pattern under taskMongo/: create, updateBasic, updateAssignment,
updateMeta, structural, mergeDuplicate, internals. Methods merge into
Task.prototype via Object.assign so internal `this.X(...)` calls and
the `taskMongo` singleton work unchanged.
- Modules/LogTime/controllerV2.js (1,739 -> 12 lines)
Re-export index over controllerV2/: helpers (3 utilities used by
Tasks/EstimatedTime), manualLogtime, tracker, capture, timelog.
Internal `exports.updateRemainingTime(...)` etc. rewritten to direct
calls now that the utilities live in a sibling helpers.js.
- Modules/Milestone/controller.js (1,193 -> 10 lines)
Re-export index over controller/: helpers (6 notification/history
utilities), crud, status, query.
- Modules/Auth/controller.js (1,061 -> 11 lines)
Re-export index over the existing controller/ folder, adding
authHelpers (5 internal helpers, incl. the externally-imported
addAndRemoveUserInMongodbNotificationCount), register, loginSession,
password.
Verification: jest 20/20 passing; smoke-checked all 80 original exports
remain reachable on their original module paths; loaded routes.js and
every downstream consumer (Company/controller, CheckInstallStep/createCompany,
EstimatedTime/controller, Auth/controller/createUser) without errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): relocate stranded verify*Auth helpers + repair inline require paths (#164)
Follow-up to 5c24855. The original splitter only tracked top-level
`exports.NAME = ...` declarations, so five non-exported helpers between
`registerAuth` and `verifyAuth` in the source file (verifyGithubAccessToken,
verifyGithubAuth, verifyGoogleIdToken, verifyGoogleAuth, verifyLocalAuth)
got swallowed into `registerAuth`'s line range and ended up in
`controller/register.js`, while their only caller β `verifyAuth` β landed
in `controller/authHelpers.js`. Every login attempt threw
`ReferenceError: verifyLocalAuth is not defined` at runtime.
The previous smoke test missed it because it only checked
`typeof exports.verifyAuth === 'function'`, which is true even when calling
the function blows up.
Changes:
- Move the 5 verify*Auth helpers into authHelpers.js, placed before
verifyAuth so their definitions are in scope at call time.
- Patch inline `require()` paths that the preamble-rewriter didn't touch
(they live inside function bodies, not at file top):
* authHelpers.js: `../Template/passwordExpiredMail` -> `../../Template/passwordExpiredMail`
* authHelpers.js: `../Template/forgotPassword` -> `../../Template/forgotPassword`
* Drop the inline `const logger = require("../../Config/loggerConfig")`
inside verifyGoogleAuth β it was already redundant in the original
(logger is imported at the top) and now also pointed to a non-existent
depth.
- register.js trimmed to 31 lines (preamble + registerAuth only).
Verification:
- New smoke test actually invokes `verifyAuth` with each authProvider
('local', 'github', 'google') and asserts the early-return validation
message β proving every verify* helper symbol resolves at runtime.
- Verified the two inline template require specifiers resolve to real
files via `require.resolve`.
- Grep-confirmed no stale `../Template/` or `../../Config/` paths
survive in any Auth sub-file.
- `npm test` 20/20 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai-project): default ProjectType to "Fix" to match manual flow
The two valid project types in AlianHub are "Fix" and "Hourly" (see
frontend/src/components/atom/ProjectType/ProjectType.vue). The manual
project creation flow hardcodes "Fix" on submit
(CreateProjectSidebar.vue, TemplateAllDetail.vue), and downstream
features like milestones (ProjectDetail.vue) only branch on those two
values.
The AI project generator was instead:
- Defaulting to "General" in the orchestrator and Zod schema
- Telling the LLM to invent ProjectType values like "Software",
"Marketing", "Operations" β none recognised by the rest of the app
Changes:
- orchestrator.js: always assign ProjectType: "Fix" (drop LLM-supplied
value).
- schemaValidator.js: default changed from "General" to "Fix" (defence
in depth for any other consumers of the schema).
- promptTemplates.js: removed the ProjectType field from the LLM
schema description so the model doesn't waste tokens generating an
ignored value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(mongo): bound the per-tenant Mongoose connection pool (#162)
Previously the per-tenant connection registry grew unbounded β every active
company kept a Mongoose connection forever (helper.js had a 30min idle sweep,
but no cap and the threshold was hardcoded). Under heavy multi-tenant load
this leaked sockets and RAM.
- Add LRU eviction in handleConnection: before opening a new connection,
evict the least-recently-used entry that's outside a 5s grace window
(the raw Connection is returned to callers, so we must not close one
that may still be servicing a query).
- Make the idle sweep env-configurable: TENANT_CONNECTION_IDLE_MS and
TENANT_CONNECTION_SWEEP_MS, defaults preserve the prior 30min/5min
behavior. Iterate in reverse during sweep to avoid splice-skip.
- Centralize cleanup in closeAndRemove helper (also drops two debug
console.logs that printed the full connection list every sweep).
- Document MAX_TENANT_CONNECTIONS (default 100), TENANT_CONNECTION_IDLE_MS,
and TENANT_CONNECTION_SWEEP_MS in .env.example. Fresh setups pick these
up automatically via scripts/dev.js's full-file copy; existing .envs
rely on code defaults (no patching of patchMissingEnvKeys β these are
optional tuning knobs, not boot-required).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(logging): replace console.log with Winston logger across modules (#165) (#177)
Replace all 56 console.log occurrences across 15 files in Modules/ with
the existing Winston logger (Config/loggerConfig.js), applying correct
log levels: error for catch blocks, warn for rejected batch items, and
info for progress/completion messages.
Add logger import to files that lacked it: Tasks/routes.js,
Project/controller/getSprintFolder.js, CheckInstallStep/controller.js,
and CheckInstallStep/initalizations.js.
The console.log inside the Firebase service-worker template string in
CheckInstallStep/controller.js is intentionally preserved β it is
browser-side code written to a .js file, not server-side Node.js.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tasks): multi-select bulk actions across List/Kanban/Table views
Adds an end-to-end ClickUp-style bulk-action experience to the project
task views with full parity to single-task semantics.
Frontend
- New Vuex module `TaskSelection` + `useTaskSelection` composable;
selection auto-clears on view/project switch.
- Hover/selected checkboxes added to ItemList rows, sprint headers,
Kanban cards/columns, and Table rows/group headers β all gated by
the same `checkPermission` keys the single-task path uses.
- Kanban drag is disabled when 2+ tasks are selected so multi-move
goes through the bar (no ambiguous single-vs-multi drag).
- New `BulkActionBar` (with `BulkMenu` sub-component) mounted in
Projects.vue. Single-open menu state with a capture-phase document
listener that ignores teleported popups (.dp__menu / sidebar /
drop-down-menu) so the calendar and assignee pickers work cleanly.
- Internal search in Status / Priority / Assignees / Tags menus.
- Assignee + tag rows: click to add, X icon to remove, with tri-state
(`none` / `some` / `all`) computed against the current selection.
- Due date uses the existing `DueDateCompo` calendar, center-aligned
in a custom wrapper.
- Optimistic store updates for delete/archive (deletedStatusKey +
sprint count adjustment) and for status/priority/assignee/tags/
due-date so the UI reflects changes immediately, matching the
single-task pattern.
- Skipped/error toasts surface backend permission/scope failures
with human-readable reasons.
Backend
- New `Modules/Tasks/helpers/taskMongo/bulk.js` mixin with
bulkUpdateStatus, bulkUpdatePriority, bulkUpdateAssignee,
bulkUpdateDueDate, bulkUpdateStartDate, bulkUpdateTags,
bulkArchive, bulkRestore, bulkDelete, bulkMove, bulkDuplicate.
- New POST /api/v2/tasks/bulk route with dynamic action dispatch,
mirroring the existing PATCH /api/v2/tasks pattern. companyId is
taken from the verified header (never the body) to prevent spoof.
- Each bulk method does the DB write directly via updateMany for
reliability, then calls HandleHistory + HandleBothNotification
per task so activity logs and notifications match what N single
calls would produce.
- Per-task `socketEmitter.emit('update', ...)` is fired with a
merged post-update task doc so taskSocket.js fans the change
out to all clients viewing that project+sprint room in real time
with the correct new values (not stale ones).
- bulkArchive/bulkDelete/bulkRestore go through the existing
`updateArchiveDelete` helper to preserve subtask cascades,
parent count updates, sprint reconciliation, and comment count
cleanup.
Permissions (gated at three layers)
- Selection visibility: hover checkbox only renders if the user
has `task.task_status` on that project.
- Action button gating: each bar action ties to the existing
per-action permission key (task.task_delete, task.task_priority,
task.task_tag, etc.); buttons disable with a tooltip.
- Backend re-check: companyId scoping via loadScopedTasks drops
cross-tenant ids into `skipped[]` before any write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(aipg): collapse plan flow to one-shot, drop clarification + task-count slider
Removes the multi-turn clarification round-trip that was causing
"Conversation not found or expired" errors on staging-app β the proxy
intermittently 504s on /api/v1/ai/project/clarify and by the time the
user retries, the cached conversation has expired so the request 404s.
What changed:
- /api/v1/ai/project/clarify is replaced with a 410 Gone stub (kept
for one deploy cycle so any cached frontend that still POSTs there
gets a clear "retry /plan" response instead of a 404).
- /api/v1/ai/project/plan is now a single round-trip. The conversation
cache (myCache "convo:" keys), conversationId param, and clarifyRound
bookkeeping are gone.
- Frontend "Quick questions to sharpen the plan" card removed.
- Frontend "Target task count" slider card removed. The model now picks
the task count entirely from the description + ruleset (4-8 tasks per
sprint, hard ceiling of 100 plan-wide).
- The error UI on step 1 now shows a retry hint and the primary button
re-labels itself to "Try again" β clicking it just re-runs the same
/plan call. No state to lose between attempts.
System prompt change:
- "Clarification rule" now says: never ask questions, always set
needsClarification=false, fill in reasonable defaults for any unclear
bits and note assumptions inside task descriptions.
- "Target task count floor" language stripped; let the model pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(table): adjust table layout and spacing for improved readability
* Fix comment duplicates and preserve drafts
* feat(ai-project-generator): implement async project plan generation with SSE support
* perf(socket): phase 1 quick-wins for real-time scalability
Five low-risk, backend-only optimizations to the socket event pipeline
and MongoDB connection layer. Wire protocol (event names, payloads) is
unchanged β no frontend changes required.
Fix #2 β Namespaced socket events
Wrap the internal EventEmitter so payloads tagged with `module` also
publish a `<module>:<event>` event. Migrate the 5 socket controllers
and the notification middleware to subscribe to the namespaced form.
Stops every task/comment/companies/notification handler from waking
up on every mutation across the system.
Fix #3 β Remove JSON.parse(JSON.stringify) from socket hot paths
Drop five deep clones across taskSocket / commentSocket /
companiesSocket that existed only to coerce ObjectId to string. Field
access works directly on the doc; template literals already call
toString().
Fix #4 β Auto-cleanup socketRef.rooms on disconnect
Listen to Socket.io's native `disconnect` event to purge stale room
entries. The existing `disconnectNameSpace` flow only fires when the
client explicitly emits it β browser close / network drop / mobile
background kill previously left dead entries piling up forever.
Fix #6 β MongoDB pool sizing
Bump maxPoolSize 3 -> 10 (env: MONGO_POOL_SIZE), add minPoolSize 2
(env: MONGO_MIN_POOL_SIZE). The 3-connection cap queued any 4th
concurrent query per tenant β task-heavy flows easily saturated it.
Fix #13 β Fail fast on queue saturation
Reduce waitQueueTimeoutMS 30000 -> 5000 (env: MONGO_WAIT_QUEUE_TIMEOUT_MS).
A queued query that can't get a socket inside 5s is almost always
doomed; failing fast surfaces the real problem instead of pinning a
request worker for 30s.
Verification
- npm test: 20/20 pass
- node --check clean on all 9 touched files
- Namespaced emitter routing smoke-tested
- Confirmed no `socketEmitter.on('update'|'insert'|...)` legacy
subscriptions remain anywhere in the codebase
- Frontend socket inventory audited β every emit/on event name is
preserved on the wire; no client changes required
See .claude/SOCKET-PERFORMANCE-PLAN.md for the full multi-phase plan
and the remaining fixes scheduled for phases 2-4.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(socket): phase 2 core refactor β Map-based room index
Phase 2 of SOCKET-PERFORMANCE-PLAN. Backend-only; wire protocol unchanged.
Fix #1 β Map-based room index (socket/helper.js)
Replace the linear `exports.rooms = []` array (scanned by .filter() on
every event) with two Maps:
byPrefix : prefix -> Map<roomName, entry> (O(1) handler lookup)
bySocket : socket -> Set<roomName> (O(rooms/socket) cleanup)
Room names already follow `<prefix>**<socketId>`; we index by exactly
that prefix. New API: upsertRoom / removeRoom / removeBySocket /
findRoomsByPrefix / findRoomsByPrefixes.
Fix #5 β data.socket.rooms.has(data.roomName) liveness guard
Replaces `Array.from(data.namespace.adapter.rooms.keys()).filter(...)`
inside every handler. socket.rooms is a small Set (3-5 entries per
socket); .has() is O(1) vs the previous full-namespace scan.
Fix #7 β Shared upsertRoom helper
Idempotent on roomName via Map.set semantics β tab refresh / reconnect
no longer creates duplicate index entries that fired the same event to
the same room multiple times. Replaces hand-rolled findIndex /
push-or-replace dedup that was inlined inconsistently across the 5
controllers.
Fix #9 β Cache getTotalSprintCount (modules/Tasks/helpers/mongo_helper.js)
Wrap the aggregate-pipeline + plan-feature check in node-cache with a
30s TTL keyed by `sprintPlanCheck:<companyId>:<sprintId>`. Trade-off
(documented inline): up to 30s of over-allocation after limit hit, or
30s of denial after a plan upgrade. Acceptable for soft plan caps.
socket/socketinit.js
Drop `exports.rooms = []`. Disconnect handler now calls
helper.removeBySocket(socket). disconnectNameSpace simplified from the
recursive countFunction to a plain forEach over adapter.rooms.
Verification
- npm test: 28/28 pass (20 existing + 8 new tests/socket-room-index.test.js)
- .claude/tests/smoke-phase2.js: emitter -> namespaced handler -> Map
index -> namespace.to().emit() flow verified end-to-end
- All touched files `node --check` clean
- Frontend audit (frontend/src/**): all wire events (joinX/leaveX, taskX,
chatTaskX, commentX, companiesX, userIdNoticationUpdate) and room-name
`<prefix>**<socketId>` convention unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tasks): resolve list-view assignee TypeError after Task.vue refactor
The Task.vue mega-component split (#167) extracted changeAssignee into the
useTaskMutations composable and passed assigneeInProgress through the template
as a third argument. Vue 3 auto-unwraps top-level refs in templates, so the
composable received the inner {} object instead of the ref β making
assigneeInProgress.value undefined and throwing
"Cannot read properties of undefined (reading '<userId>')" on every assignee
click in list view.
Move the ref into the composable (matching useProjectAssignee.js) so closure
ownership replaces the broken template hand-off.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* In progress changes commit
* fix(ai-project-generator): unblock execute hang, fix list rendering, tighten prompt splitting
Bug fixes:
- orchestrator: wrap critical steps (checkProjectPlan, loadCompanyContext,
generateUniqueProjectCode, saveProject) with 45s timeouts so a stuck
MongoDB call surfaces a clear error instead of freezing the UI forever
- orchestrator: roll back the project count whenever checkProjectPlan
incremented it, not only when a project doc was saved. The old logic
leaked +1 per failed attempt and eventually tripped the plan limit
- orchestrator: normalize list items to @editorjs/nested-list's
{ content, items: [] } shape β the LLM emits plain strings, which the
editor renders as literal "undefined"
- orchestrator: drop bogus .catch() on removeProjectCount() (it returns
undefined, not a Promise β was throwing TypeError on the error path)
- orchestrator: per-step [AIPG][jobId] logging to pinpoint future hangs
- openai provider: bump axios timeout to 10 min for reasoning/gpt-5
models (was 4 min, which the enriched prompt + 40+ task generation
routinely exceeded); env override via OPENAI_TIMEOUT_MS
Prompt improvements (task-guidance.md, examples.md, sprint-guidance.md):
- one task per screen for every team, with explicit designβbuild symmetry
- one task per HTTP method (no PUT/DELETE skips)
- always include a project scaffolding task for new codebases
- anti-patterns section calling out the bundling shapes seen in past
outputs ("X and Y", "X with Y", redundant planning docs, QA bundling)
- task count floor by project type, with pre-emit checklist
- drop "Required skills" from descriptions β team assignment alone
communicates the skill set
- bumped recommended OpenAI model in .env.example to gpt-4o / gpt-4.1
for stronger instruction following
UI:
- show the original brief filename instead of "Brief loaded Β· N tokens"
- sprint-name input takes the full row width in the plan preview
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(staging): temporarily disable hardening middleware and tenant pool cap
- index.js: comment out trust-proxy, helmet, and global rate-limit middleware
- middlewares/mongoConnector: revert issue #162 LRU-bounded tenant connection pool back to the previous unbounded behaviour with the fixed idle/sweep timers
- .env.example: drop the MAX_TENANT_CONNECTIONS / TENANT_CONNECTION_IDLE_MS / TENANT_CONNECTION_SWEEP_MS doc block that the pool cap referenced
This is a deliberate rollback on the staging branch to unblock staging-only behaviour; the hardening should be reinstated once the regression is diagnosed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Add space
* fix(promptBuilder): correct comment formatting for consistency
* fix: consolidate lowercase modules/ into Modules/ to resolve case-folding split
On Windows (case-insensitive filesystem) commits accidentally tracked 12
AIProjectGenerator files under modules/ (lowercase) while the rest of the
codebase lives under Modules/ (uppercase). The local tree merged both, hiding
the issue β but on Linux (case-sensitive) GitHub and production saw two
separate folders, breaking ./promptBuilder and path.join(__dirname, 'prompts')
require resolutions at runtime.
Move all 12 files into Modules/AIProjectGenerator/ so the folder is unified
across all platforms.
* feat(tasks): AI-estimate completion time on task create
Estimate task completion time with an LLM after every task is created
and persist the result (in minutes) to `tasks.totalEstimatedTime`. The
estimate represents the wall-clock time an AI coding agent (Claude Code)
would need to do the task end-to-end, with no manual implementation.
Why
- Gives newly-created tasks an immediate, description-driven time
estimate so planning and remaining-hours math have a value to work
with from the start.
- Reuses the existing AIProjectGenerator llmProvider factory
(Anthropic-preferred, OpenAI fallback), so installs that already
have AI configured pick this up with no new env vars.
How
- New helper `Modules/EstimatedTime/aiTaskEstimator.js`. Reads the
system prompt once at module load from a Markdown partial,
builds a structured user message from the task (title, type,
priority, parent/subtask flag, description), parses JSON,
clamps to [5 min, 7 days], $sets totalEstimatedTime, and emits
a `task` socket update. Never throws; silently no-ops when no
LLM provider is configured.
- New prompt partial `Modules/AIProjectGenerator/prompts/project-plan/
task-time-estimate.md` walks the model through deliverable shape,
surface area, ambiguity, verification, and iteration overhead β
explicitly discouraging lazy round-number defaults so the estimate
reflects the actual description.
- Hook in `Modules/Tasks/helpers/taskMongo/create.js` after the
central task `create()` succeeds (skips mainChat). Fire-and-forget,
so API responses are not delayed.
- Hook in `Modules/AIProjectGenerator/orchestrator.js` after the
bulk `insertMany` so AI-orchestrated project tasks also get
estimates. Background runner with a concurrency cap of 3 to stay
under provider rate limits when a single plan generates many tasks.
Docs
- `.claude/AI-TIME-ESTIMATION.md` explains the model the prompt uses
and how estimates flow through the app in plain language.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(estimated-time): manual AI trigger + accuracy rewrite
Three related improvements to the task time estimation feature built
in #188:
1. Manual AI trigger in the task detail sidebar
- New icon-only button next to the "Estimated" field. Tooltip
"Generate estimate using AI" via native title attribute (matches
existing project tooltip convention).
- Inline spinner + disabled state while the request is in flight;
debounce-safe (`isAiEstimateLoading` guards double-clicks).
- Backed by a new endpoint `POST /api/v1/estimatedTime/ai/:tid`
in `Modules/EstimatedTime/controller.js` that fetches the
canonical task doc server-side (so the LLM payload comes from
the DB, not stale client data), runs the estimator with the new
`force: true` flag so it overwrites any existing value, persists
`totalEstimatedTime`, and emits a Socket.io `task` update.
- Permission gate: button is inner-gated by
`checkPermission('task.task_estimated_hours', ...) === true`,
mirroring the read-only / read-write pattern Priority / Start
Date / Due Date use in the same component. Read-only users still
see the value, just not the AI trigger.
2. Accuracy rewrite of the estimator prompt
- The system prompt now walks the model through a four-phase
pipeline before producing a number:
Phase 1 β Normalize: remove redundancy, merge overlapping
requirements, drop filler, keep implicit work.
Phase 2 β Extract: enumerate the unique work items.
Phase 3 β Estimate: apply deliverable shape, surface area,
clarity, verification, and iteration overhead
to EACH item, not to the whole description.
Phase 4 β Sum: add up the per-item estimates without
double-counting shared setup.
- Output JSON now includes a `work_items[]` array β forcing
chain-of-thought enumeration before the number, which is what
structurally prevents both inflation (no item -> no minutes) and
underestimation (each implicit item must be named).
- Four worked examples baked into the prompt cover redundancy,
overlapping requirements, filler stripping, and implicit work.
- Parser is backward compatible: still consumes only `minutes`,
so tasks estimated by the old prompt shape keep working.
3. Estimator tuning
- `maxTokens: 256 -> 1024` so the work_items array doesn't get
truncated mid-JSON (truncated response = parser returns null =
estimate skipped, so this directly affects success rate).
- `temperature: 0.2 -> 0.15` pulls the model toward deterministic,
calibrated outputs β estimation should not be creative.
- New `normalizeDescriptionForPrompt` helper strips trailing
whitespace and collapses 3+ blank or 3+ accidental-duplicate
lines so the model spends its reasoning budgβ¦
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
screenshotRetentionMap field on the global companies schema. Storesenabled,maxAgeMonths,enabledAt,enabledBy,lastRunAt,lastRunStats,firstRunCompletedAt,runningSince. Backward-compatible β legacy companies without the field read asenabled: 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
runningSincelock preventsdouble-runs; stale locks (>4h) are reclaimed.
* First-run safety cap (50k deletions) for the
initial cleanup on legacy data; lifted once
firstRunCompletedAtis 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_usersdoc for the caller and confirmsroleType === 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 invokesscreenshotRetention.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)
RetentionAuditLogcollection for per-run history beyondlastRunStats. The cron emits structured logs in the meantime.Pull Request Template Chooser
Please click the link that matches your contribution type to load the correct format.
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.