Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5f5a9dd
:hammer: update developer tooling to latest dev command helpers.
klondikemarlen Oct 28, 2025
89f43a8
:broom: Remove unused import from integration controller.
klondikemarlen Oct 28, 2025
b3bcdfd
:see_no_evil: Hide claude code local configuration file.
klondikemarlen Oct 28, 2025
4ccefc4
:butterfly: Add submissions table to track per source submissions.
klondikemarlen Oct 28, 2025
6902899
:hammer: Add AGENTS.md file.
klondikemarlen Oct 28, 2025
972ccfa
:abc: Alphabetize model exports.
klondikemarlen Oct 28, 2025
7a12b61
:ok_hand: standardize model import pattern.
klondikemarlen Oct 28, 2025
084e7f2
:sparkles: Add submission model to track submissions per source.
klondikemarlen Oct 28, 2025
0cbe5cd
:sparkles: Add ordering support to base controller.
klondikemarlen Oct 28, 2025
d9d4658
🔨 Add init: true to core services so they accept SIGINT and SIGTERM.
klondikemarlen Oct 28, 2025
a05928d
🐛 Fix vuetify build and import via automatic treeshaking.
klondikemarlen Oct 28, 2025
0d40071
:art: Clean up and alphabetize exports.
klondikemarlen Oct 28, 2025
3fcf5f0
:recycle: Standardize base policy.
klondikemarlen Oct 28, 2025
66e9435
:construction: Comment out integration controller as we are replacing…
klondikemarlen Oct 28, 2025
7639752
:art: Simplify policy logic now that user is a required param.
klondikemarlen Oct 28, 2025
b5b5b40
:abc: Alphabetize controller exports.
klondikemarlen Oct 28, 2025
a44a8e5
:construction: Add intial submissions controller with stub creation a…
klondikemarlen Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,6 @@ db/data
note.md
now.md
now.sql

# Claude Code local settings
.claude/settings.local.json
227 changes: 227 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# WRAP - Workflow Routing and Approval Platform

WRAP is a full-stack workflow management application for the Yukon Government. API: Node.js + Express + TypeScript + MSSQL. Web: Vue 3 + Vuetify + TypeScript. Tests use Vitest with Fishery factories.

This file follows the format from https://agents.md/ for AI agent documentation.

## Dev environment tips

- Start all services: `dev up` (or `docker compose -f docker-compose.development.yml up`)
- Start API only: `dev up api` - access at http://localhost:3000
- Start web only: `dev up web` - access at http://localhost:8080
- Stop and wipe database: `dev down -v`
- Access mail server UI: http://localhost:1080
- Access database CLI: `dev sqlcmd`
- Node debugging: `dev debug` or use Chrome at `chrome://inspect`
- Test files mirror source structure: `api/src/services/example.ts` → `api/tests/services/example.test.ts`
- Use `@/` import alias for src directory in both API and web
- Database uses snake_case, models use camelCase (field mapping handles conversion)
- Migrations run automatically on `dev up`, or manually via `dev migrate latest`
- Create migration: `dev migrate make create-table-name`
- Create seed: `dev seed make fill-table-name`

## Testing instructions

**Running tests:**
- Run all API tests: `dev test_api` (from project root)
- Run specific test file: `dev test api/tests/services/example.test.ts -- --run` (from project root)
- Run specific test file (alternative): `npm test -- tests/services/example.test.ts --run` (from `/api`)
- Watch mode: `dev test api/tests/services/example.test.ts` (from project root, omit `--run`)
- Watch mode (alternative): `npm test -- tests/services/example.test.ts` (from `/api`)
- Test specific pattern: `npm test -- --grep "creates a delegation"` (from `/api`)

**Test structure:**
- Test files mirror source structure: `api/src/services/example.ts` → `api/tests/services/example.test.ts`
- Use Fishery factories from `@/factories` for all test data (e.g., `userFactory.create()`)
- Tests automatically clean database before each test via `setup.ts` - no manual cleanup needed
- Always use AAA pattern with explicit comments: `// Arrange`, `// Act`, `// Assert`
- Test naming format: `"when [condition], [expected behavior]"` - use full entity names, not abbreviations (e.g., "workflow step player" not "player")
- Use `test` not `it` for test blocks
- Test files use nested describe blocks: file path → class name → method name (`.perform`)

**Test variable naming:**
- Use numbered entities: `user1`, `user2`, `position1`, `position2` (not `existingUser`, `newUser`, `currentUser`)
- Use most specific, descriptive variable names: `workflowStepPlayersAttributes` not `playersAttributes`
- Always create `userOrganization` relationships after creating users and organizations: `await userOrganizationFactory.create({ userId: user1.id, organizationId: organization.id })`

**Test assertions:**
- In service tests, use `findAll()` without where clauses to assert database state (test isolation handles cleanup)
- Focus assertions on database state, not service return values (unless specifically testing return values)
- For arrays of objects: `expect(result).toEqual([expect.objectContaining({ id: workflow.id })])`
- For errors: `await expect(service.perform(data)).rejects.toThrow("error message")`
- For spies: `const spy = vi.spyOn(Service, "perform").mockResolvedValue(result)`
- **For negative spy assertions:** Use `expect(spy).not.toHaveBeenCalled()` without arguments - never use `not.toHaveBeenCalledWith(...)` as it can create unsafe tests that pass but don't validate what you expect
- Controller tests: use `mockCurrentUser(user)` and `request().get("/api/path")` from `@/support`
- All tests must pass before committing

## Code style guidelines

- TypeScript for all new code - no `any`, no `@ts-expect-error`, no `@ts-ignore`, no `!` (non-null assertion)
- **No non-null assertions:** Never use `!` operator - use proper null handling with optional chaining (`?.`), nullish coalescing (`??`), or explicit type guards
- 2 spaces, no semicolons, double quotes, 100 char line limit (see `.prettierrc.yaml`)
- **No abbreviations:** Use full descriptive names for variables, functions, tables (e.g., `workflow` not `wf`, `migration` not `mig`)
- **SQL:** Fully spell out table names and column names - no abbreviated aliases
- **Number similar entities:** `user1`, `user2`, `position1`, `position2` for clarity
- **Expanded code style:** One thing per line, avoid terse functional chains
- **Guard clauses:** Early returns with blank line after each guard
- **Named constants:** Hoist magic numbers to named `const` at top of function/file
- camelCase for variables/functions, PascalCase for classes/types
- Services use static `.perform()` method and encapsulate business logic
- Services call other services, not queries directly (migrating away from separate query files)
- Test files use nested describe blocks: file path → class name → method name (`.perform`)
- **Import ordering (PEP8-style):**
1. Node.js built-in modules (e.g., `path`, `fs`)
2. Blank line
3. External packages from node_modules (e.g., `express`, `lodash`, `@sequelize/core`)
4. Blank line
5. Internal imports from `@/` (config, utils, models, services, controllers, etc.)
- Optionally group internal imports by category with blank lines between groups
- Example: config imports, blank line, middleware imports, blank line, controller imports
- Within each section, alphabetical ordering is strongly encouraged

## Architecture patterns

- **Service pattern:** Business logic in services with static `.perform()` methods
- All services extend `BaseService` which provides a static `perform()` method
- **ALWAYS call services using the static method:** `ServiceName.perform(args)`
- **NEVER instantiate services directly:** `new ServiceName(args).perform()` is incorrect
- The static `perform()` method handles instantiation internally and ensures proper type inference
- Example correct: `await EnsureForWorkflowStepPlayerService.perform(workflowId, playerId)`
- Example incorrect: `await new EnsureForWorkflowStepPlayerService(workflowId, playerId).perform()`
- **Factory pattern:** Use Fishery factories for test data creation
- **Policy pattern:** Authorization scoping via policy classes
- **Access control:** Direct user, position-based, team-based, position-team access patterns
- **Delegations:** Workflow/step player delegation with automatic cleanup
- **Soft deletes:** Models support `deletedAt` timestamp
- **Database:** Knex for migrations, Sequelize for ORM

## Security considerations

- Auth0 for authentication (requires third-party cookies in dev)
- All routes require authentication by default unless explicitly public
- Use policy scoping for user-specific data
- Never commit secrets - use environment variables
- Validate all user inputs
- Parameterized queries prevent SQL injection

## PR instructions

- Run `npm test` from `/api` - all tests must pass
- Fix any TypeScript errors - no `@ts-ignore` allowed
- Follow naming conventions - no abbreviations
- Write tests for new functionality following AAA pattern
- Use descriptive commit messages
- Reference Jira tickets when applicable
- Never `git push --force` on main branch
- Use `git push --force-with-lease` for feature branches if needed

**PR Testing Instructions Format:**

Always include a "Testing Instructions" section in PRs with numbered UI steps:

1. Start with standard setup steps:
- `1. Run the test suite via 'dev test' (or 'dev test_api')`
- `2. Boot the app via 'dev up'`
- `3. Log in to the app at http://localhost:8080`

2. Navigation steps should reference specific UI elements:
- Use exact button names: **Add User**, **Activate Position**, **Create Delegation**
- Reference menu locations: "top right dropdown nav", "left sidebar nav"
- Reference tabs by name: **Users** tab, **Positions** tab
- Use navigation arrows: **Administration** → **Positions** → **Users** tab

3. Organize complex testing into test cases:
- Use `## Test Case N: Description` subheadings for multiple scenarios
- Number steps sequentially across all test cases (don't restart at 1)
- Include expected outcomes: "Verify success message: 'X created!'"
- Test both success and failure paths when relevant

4. Verification steps should be explicit:
- "Verify the table displays **Column Name** column"
- "Verify dates are formatted correctly"
- "Verify error message contains: 'exact error text'"
- "Verify you are redirected to the X page"

5. Include specific test data when helpful:
- Example values: "Select '2025-10-15'" or "Enter 'Alice Smith'"
- Specific URLs: `http://localhost:8080/administration/users/2/positions`

6. Format for readability:
- Use bold for UI elements: **button names**, **field labels**, **page names**
- Use inline code for: exact error messages, URLs, field values
- Break complex forms into bullet lists with field names

Example pattern:
```markdown
## Testing Instructions

1. Run the test suite via `dev test` (or `dev test_api`)
2. Boot the app via `dev up`
3. Log in to the app at http://localhost:8080
4. Navigate to the Admin dashboard via the top right dropdown nav
5. Go to the **Positions** page via the left sidebar nav
6. Click on any position that has users assigned
7. Click the **Users** tab
8. Verify the table displays **Start Date** and **End Date** columns
9. Click the **Add User** button
10. Fill in the form:
- **User**: Select any user
- **Start Date**: Select a date
11. Click **Add User** to submit
12. Verify success message: "User added!"
```

## Configuration

Environment files (not committed):
- `.env.development` - Development config
- `.env` - Production config

Required variables:
- `DB_*` - Database connection (MSSQL)
- `VITE_AUTH0_*` - Auth0 authentication
- `MAIL_*` - Email server config
- `AD_*` - Active Directory integration

See `.env.example` files in `/api` for full list of required variables.

## Common factories

Import from `@/factories`:
- `userFactory`, `organizationFactory`, `userOrganizationFactory`
- `positionFactory`, `teamFactory`, `userPositionFactory`, `userTeamFactory`, `positionTeamFactory`
- `workflowFactory`, `workflowPlayerFactory`, `workflowAccessByUserFactory`
- `workflowStepFactory`, `workflowStepPlayerFactory`
- `delegationFactory`, `notificationFactory`

Example:
```typescript
// Example from tests/services/users/ensure-from-auth0-token-service.test.ts
describe("api/src/services/users/ensure-from-auth0-token-service.ts", () => {
describe("EnsureFromAuth0TokenService", () => {
describe("#perform", () => {
test("when user with Auth0 subject, matching Auth0 subject returned by Auth0 integration, exists in database, returns user", async () => {
// Arrange
const token = "Auth0AccessToken"
const auth0Subject = "auth0|74df9b33217f9d4c8fefcc8b"
const user = await userFactory.create({ auth0Subject })

const getUserInfoResult = {
auth0Subject,
email: "jane.doe@example.com",
firstName: "Jane",
lastName: "Doe",
externalDirectoryIdentifier: "123456",
}
mockedAuth0Integration.getUserInfo.mockResolvedValue(getUserInfoResult)

// Act
const result = await EnsureFromAuth0TokenService.perform(token)

// Assert
expect(result).to.be.instanceOf(User).with.property("id", user.id)
})
})
})
})
```
33 changes: 31 additions & 2 deletions api/src/controllers/base-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express"
import { Attributes, Model, WhereOptions } from "@sequelize/core"
import { isEmpty } from "lodash"
import { Attributes, Model, Order, WhereOptions } from "@sequelize/core"
import { dropRight, isEmpty, isNil, uniqBy } from "lodash"

import User from "@/models/user"
import { type BaseScopeOptions } from "@/policies"
Expand All @@ -13,6 +13,16 @@ type ControllerRequest = Request & {
currentUser: User
}

/** Keep in sync with web/src/api/base-api.ts */
export type ModelOrder = Order &
(
| [string, string]
| [string, string, string]
| [string, string, string, string]
| [string, string, string, string, string]
| [string, string, string, string, string, string]
)

// Keep in sync with web/src/api/base-api.ts
const MAX_PER_PAGE = 1000
const MAX_PER_PAGE_EQUIVALENT = -1
Expand Down Expand Up @@ -192,6 +202,25 @@ export class BaseController<TModel extends Model = never> {
return scopes
}

buildOrder(
overridableOrder: ModelOrder[] = [],
nonOverridableOrder: ModelOrder[] = []
): ModelOrder[] | undefined {
const orderQuery = this.query.order as unknown as ModelOrder[] | undefined

if (isNil(orderQuery)) {
return [...nonOverridableOrder, ...overridableOrder]
}

const order = [...nonOverridableOrder, ...orderQuery, ...overridableOrder]
const uniqueOrder = uniqBy(order, (order) => {
const orderExcludingDirection = dropRight(order)
return orderExcludingDirection.join(".").toLowerCase()
})

return uniqueOrder
}

private determineLimit(perPage: number) {
if (perPage === MAX_PER_PAGE_EQUIVALENT) {
return MAX_PER_PAGE
Expand Down
17 changes: 10 additions & 7 deletions api/src/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Controllers
export { ArchiveItemsController } from "./archive-items-controller"
export { ArchiveItemFilesController } from "./archive-item-files-controller"
export { ArchiveItemAuditsController } from "./archive-item-audits-controller"
export { CurrentUserController } from "./current-user-controller"
export { IntegrationController } from "./integration-controller"
export { UsersController } from "./users-controller"
export { SourcesController } from "./sources-controller"
export { RetentionsController } from "./retentions-controller"
export { ArchiveItemFilesController } from "./archive-item-files-controller"
export { ArchiveItemsController } from "./archive-items-controller"
export { CategoriesController } from "./categories-controller"
export { CurrentUserController } from "./current-user-controller"
export { DecisionsController } from "./decisions-controller"
// export { IntegrationController } from "./integration-controller"
export { RetentionsController } from "./retentions-controller"
export { SourcesController } from "./sources-controller"
export { UsersController } from "./users-controller"

// Bundled exports
export * as Sources from "./sources"
Loading