diff --git a/.gitignore b/.gitignore index e27cb2a..856f359 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,6 @@ db/data note.md now.md now.sql + +# Claude Code local settings +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..54695db --- /dev/null +++ b/AGENTS.md @@ -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) + }) + }) + }) +}) +``` diff --git a/api/src/controllers/base-controller.ts b/api/src/controllers/base-controller.ts index a9fa992..0ffd579 100644 --- a/api/src/controllers/base-controller.ts +++ b/api/src/controllers/base-controller.ts @@ -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" @@ -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 @@ -192,6 +202,25 @@ export class BaseController { 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 diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index f131e1f..38ec87b 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -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" diff --git a/api/src/controllers/integration-controller.ts b/api/src/controllers/integration-controller.ts index 3666baf..9db694d 100644 --- a/api/src/controllers/integration-controller.ts +++ b/api/src/controllers/integration-controller.ts @@ -1,160 +1,160 @@ -import logger from "@/utils/logger" -import { isArray, isNil, uniqBy } from "lodash" -import { ArchiveItem, ArchiveItemAudit, Category, Source, User, UserPermission } from "@/models" -import { IntegrationsPolicy } from "@/policies" -import BaseController from "@/controllers/base-controller" -import { CreateIntegrationService } from "@/services/archive-items" -import { Op } from "@sequelize/core" -import { DateTime } from "luxon" -import { FRONTEND_URL } from "@/config" - -export class IntegrationController extends BaseController { - async create() { - try { - const source = await Source.findByPk(this.request.params.sourceId) - if (isNil(source) || isNil(source.referrers)) { - return this.response.status(404).json({ - message: "Source not found", - }) - } - - if (!source.referrers.includes(this.request.ip ?? "")) { - return this.response.status(401).json({ - message: "Source not authorized from this IP", - }) - } - - const policy = this.buildPolicy(source) - if (!policy.create()) { - return this.response.status(403).json({ - message: "You are not authorized to create items", - }) - } - - if (isNil(this.request.body.categories) || this.request.body.categories.length === 0) { - return this.response.status(401).json({ - message: "Category is required", - }) - } - - const categoryNames = this.request.body.categories - .split(",") - .map((categoryName: string) => categoryName.trim()) - .filter((categoryName: string) => !isNil(categoryName)) - - const matchingCategories = await Category.findAll({ - where: { - name: { [Op.in]: categoryNames }, - }, - include: ["retention"], - }) - - if ( - isNil(matchingCategories) || - matchingCategories.length === 0 || - matchingCategories.length != categoryNames.length - ) { - return this.response.status(401).json({ - message: "Categories do not exist", - }) - } - const matchingCategoryIds = matchingCategories.map((category) => category.id) - - const retentionOptions = uniqBy( - matchingCategories.map((category) => category.retention), - "id" - ).filter((retention) => !isNil(retention)) - - let retentionName = "" - let calculatedExpireDate = DateTime.now() - let expireAction = "" - - for (const retention of retentionOptions) { - - console.log(retention) - - if (retention.retentionDate) { - const calculatedExpireDate1 = DateTime.fromJSDate(retention.retentionDate) - console.log(calculatedExpireDate1, calculatedExpireDate) - - if (calculatedExpireDate1 > calculatedExpireDate) { - retentionName = retention.name - expireAction = retention.expireAction - calculatedExpireDate = calculatedExpireDate1 as DateTime - } - } else if (retention.retentionDays) { - const calculatedExpireDate1 = DateTime.now() - .set({ hour: 23, minute: 59, second: 59, millisecond: 0 }) - .toUTC() - .plus({ days: retention.retentionDays }) - - if (calculatedExpireDate1 > calculatedExpireDate) { - retentionName = retention.name - expireAction = retention.expireAction - calculatedExpireDate = calculatedExpireDate1 as DateTime - } - } - } - if (retentionName === "" || expireAction === "") { - return this.response.status(401).json({ - message: "Retention is invalid", - }) - } - - const permittedAttributes = policy.permitAttributesForCreate(this.request.body) - const archiveItem = await CreateIntegrationService.perform({ - ...permittedAttributes, - categoryIds: matchingCategoryIds, - files: this.request.body.files, - isDecision: false, - source, - retentionName, - calculatedExpireDate: calculatedExpireDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), - expireAction, - securityLevel: 2, - }) - - const permissionsList = this.request.body.emails - .split(",") - .map((email: string) => email.trim()) - - for (const item of permissionsList) { - const user = await User.findOne({ where: { email: item } }) - - UserPermission.create({ - canViewAttachments: true, - archiveItemId: archiveItem.id, - userEmail: user ? null : item, - userId: user?.id, - }) - } - - await ArchiveItemAudit.create({ - archiveItemId: archiveItem.id, - action: "Created", - description: `${source.name} created item`, - }) - - console.log(`${FRONTEND_URL}/archive-items/${archiveItem.id}/view`) - - const data = { - id: archiveItem.id, - url: `${FRONTEND_URL}/archive-items/${archiveItem.id}/view`, - submittedAt: archiveItem.submittedAt, - } - - return this.response.status(201).json({ data }) - } catch (error) { - logger.error("Error creating archive item" + error) - return this.response.status(422).json({ - message: `Error creating archive item: ${error}`, - }) - } - } - - private buildPolicy(source: Source, archiveItem: ArchiveItem = ArchiveItem.build()) { - return new IntegrationsPolicy(source, archiveItem) - } -} - -export default IntegrationController +// import logger from "@/utils/logger" +// import { isNil, uniqBy } from "lodash" +// import { ArchiveItem, ArchiveItemAudit, Category, Source, User, UserPermission } from "@/models" +// import { IntegrationsPolicy } from "@/policies" +// import BaseController from "@/controllers/base-controller" +// import { CreateIntegrationService } from "@/services/archive-items" +// import { Op } from "@sequelize/core" +// import { DateTime } from "luxon" +// import { FRONTEND_URL } from "@/config" + +// export class IntegrationController extends BaseController { +// async create() { +// try { +// const source = await Source.findByPk(this.request.params.sourceId) +// if (isNil(source) || isNil(source.referrers)) { +// return this.response.status(404).json({ +// message: "Source not found", +// }) +// } + +// if (!source.referrers.includes(this.request.ip ?? "")) { +// return this.response.status(401).json({ +// message: "Source not authorized from this IP", +// }) +// } + +// const policy = this.buildPolicy(source) +// if (!policy.create()) { +// return this.response.status(403).json({ +// message: "You are not authorized to create items", +// }) +// } + +// if (isNil(this.request.body.categories) || this.request.body.categories.length === 0) { +// return this.response.status(401).json({ +// message: "Category is required", +// }) +// } + +// const categoryNames = this.request.body.categories +// .split(",") +// .map((categoryName: string) => categoryName.trim()) +// .filter((categoryName: string) => !isNil(categoryName)) + +// const matchingCategories = await Category.findAll({ +// where: { +// name: { [Op.in]: categoryNames }, +// }, +// include: ["retention"], +// }) + +// if ( +// isNil(matchingCategories) || +// matchingCategories.length === 0 || +// matchingCategories.length != categoryNames.length +// ) { +// return this.response.status(401).json({ +// message: "Categories do not exist", +// }) +// } +// const matchingCategoryIds = matchingCategories.map((category) => category.id) + +// const retentionOptions = uniqBy( +// matchingCategories.map((category) => category.retention), +// "id" +// ).filter((retention) => !isNil(retention)) + +// let retentionName = "" +// let calculatedExpireDate = DateTime.now() +// let expireAction = "" + +// for (const retention of retentionOptions) { +// console.log("retention", retention) + +// if (retention.retentionDate) { +// const calculatedExpireDate1 = DateTime.fromJSDate(retention.retentionDate) +// console.log(`calculatedExpireDate1:`, calculatedExpireDate1) +// console.log("calculatedExpireDate", calculatedExpireDate) + +// if (calculatedExpireDate1 > calculatedExpireDate) { +// retentionName = retention.name +// expireAction = retention.expireAction +// calculatedExpireDate = calculatedExpireDate1 as DateTime +// } +// } else if (retention.retentionDays) { +// const calculatedExpireDate1 = DateTime.now() +// .set({ hour: 23, minute: 59, second: 59, millisecond: 0 }) +// .toUTC() +// .plus({ days: retention.retentionDays }) + +// if (calculatedExpireDate1 > calculatedExpireDate) { +// retentionName = retention.name +// expireAction = retention.expireAction +// calculatedExpireDate = calculatedExpireDate1 as DateTime +// } +// } +// } +// if (retentionName === "" || expireAction === "") { +// return this.response.status(401).json({ +// message: "Retention is invalid", +// }) +// } + +// const permittedAttributes = policy.permitAttributesForCreate(this.request.body) +// const archiveItem = await CreateIntegrationService.perform({ +// ...permittedAttributes, +// categoryIds: matchingCategoryIds, +// files: this.request.body.files, +// isDecision: false, +// source, +// retentionName, +// calculatedExpireDate: calculatedExpireDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), +// expireAction, +// securityLevel: 2, +// }) + +// const permissionsList = this.request.body.emails +// .split(",") +// .map((email: string) => email.trim()) + +// for (const item of permissionsList) { +// const user = await User.findOne({ where: { email: item } }) + +// UserPermission.create({ +// canViewAttachments: true, +// archiveItemId: archiveItem.id, +// userEmail: user ? null : item, +// userId: user?.id, +// }) +// } + +// await ArchiveItemAudit.create({ +// archiveItemId: archiveItem.id, +// action: "Created", +// description: `${source.name} created item`, +// }) + +// console.log(`${FRONTEND_URL}/archive-items/${archiveItem.id}/view`) + +// const data = { +// id: archiveItem.id, +// url: `${FRONTEND_URL}/archive-items/${archiveItem.id}/view`, +// submittedAt: archiveItem.submittedAt, +// } + +// return this.response.status(201).json({ data }) +// } catch (error) { +// logger.error("Error creating archive item" + error) +// return this.response.status(422).json({ +// message: `Error creating archive item: ${error}`, +// }) +// } +// } + +// private buildPolicy(source: Source, archiveItem: ArchiveItem = ArchiveItem.build()) { +// return new IntegrationsPolicy(source, archiveItem) +// } +// } + +// export default IntegrationController diff --git a/api/src/controllers/sources/index.ts b/api/src/controllers/sources/index.ts new file mode 100644 index 0000000..1110591 --- /dev/null +++ b/api/src/controllers/sources/index.ts @@ -0,0 +1 @@ +export { SubmissionsController } from "./submissions-controller" diff --git a/api/src/controllers/sources/submissions-controller.ts b/api/src/controllers/sources/submissions-controller.ts new file mode 100644 index 0000000..b1bc9c1 --- /dev/null +++ b/api/src/controllers/sources/submissions-controller.ts @@ -0,0 +1,231 @@ +import { isNil } from "lodash" + +import logger from "@/utils/logger" +import { Submission, Source } from "@/models" +import { SourcePolicy, SubmissionsPolicy } from "@/policies" +import { IndexSerializer, ShowSerializer } from "@/serializers/submissions" +import { CreateService } from "@/services/submissions" +import BaseController from "@/controllers/base-controller" + +export class SubmissionsController extends BaseController { + async index() { + try { + // TODO: make a "beforeEach" pattern that can run the source authorization checks. + const source = await this.loadSource() + if (isNil(source)) { + return this.response.status(404).json({ + message: "Source not found", + }) + } + + const sourcePolicy = this.buildSourcePolicy(source) + if (!sourcePolicy.show()) { + return this.response.status(403).json({ + message: "You are not authorized to view submissions for this source", + }) + } + + const where = this.buildWhere() + const scopes = this.buildFilterScopes() + const order = this.buildOrder() + const scopedSubmissions = SubmissionsPolicy.applyScope(scopes, this.currentUser) + + const totalCount = await scopedSubmissions.count({ where }) + const submissions = await scopedSubmissions.findAll({ + where, + order, + limit: this.pagination.limit, + offset: this.pagination.offset, + }) + + const serializedSubmissions = IndexSerializer.perform(submissions) + return this.response.json({ + submissions: serializedSubmissions, + totalCount, + }) + } catch (error) { + logger.error(`Error fetching submissions ${error}`, { error }) + return this.response.status(400).json({ + message: `Error fetching submissions: ${error}`, + }) + } + } + + async show() { + try { + const source = await this.loadSource() + if (isNil(source)) { + return this.response.status(404).json({ + message: "Source not found", + }) + } + + const sourcePolicy = this.buildSourcePolicy(source) + if (!sourcePolicy.show()) { + return this.response.status(403).json({ + message: "You are not authorized to view submissions for this source", + }) + } + + const submission = await this.loadSubmission() + if (isNil(submission)) { + return this.response.status(404).json({ + message: "Submission not found", + }) + } + + const policy = this.buildPolicy(submission) + if (!policy.show()) { + return this.response.status(403).json({ + message: "You are not authorized to view this submission", + }) + } + + const serializedSubmission = ShowSerializer.perform(submission) + return this.response.json({ + submission: serializedSubmission, + policy, + }) + } catch (error) { + logger.error(`Error fetching submission ${error}`, { error }) + return this.response.status(400).json({ + message: `Error fetching submission: ${error}`, + }) + } + } + + async create() { + try { + const source = await this.loadSource() + if (isNil(source)) { + return this.response.status(404).json({ + message: "Source not found", + }) + } + + const sourcePolicy = this.buildSourcePolicy(source) + if (!sourcePolicy.create()) { + return this.response.status(403).json({ + message: "You are not authorized to create submissions for this source", + }) + } + + const newSubmission = this.buildSubmission(source) + const policy = this.buildPolicy(newSubmission) + + if (!policy.create()) { + return this.response.status(403).json({ + message: "You are not authorized to create submissions", + }) + } + + const permittedAttributes = policy.permitAttributesForCreate(this.request.body) + const submission = await CreateService.perform(permittedAttributes, this.currentUser) + const serializedSubmission = ShowSerializer.perform(submission) + return this.response.status(201).json({ + submission: serializedSubmission, + policy, + }) + } catch (error) { + logger.error(`Error creating submission ${error}`, { error }) + return this.response.status(422).json({ + message: `Error creating submission: ${error}`, + }) + } + } + + async update() { + try { + const submission = await this.loadSubmission() + if (isNil(submission)) { + return this.response.status(404).json({ + message: "Submission not found", + }) + } + + const policy = this.buildPolicy(submission) + if (!policy.update()) { + return this.response.status(403).json({ + message: "You are not authorized to update this submission", + }) + } + + const permittedAttributes = policy.permitAttributes(this.request.body) + await submission.update(permittedAttributes) + + const serializedSubmission = ShowSerializer.perform(submission) + return this.response.json({ + submission: serializedSubmission, + policy, + }) + } catch (error) { + logger.error(`Error updating submission ${error}`, { error }) + return this.response.status(422).json({ + message: `Error updating submission: ${error}`, + }) + } + } + + async destroy() { + try { + const submission = await this.loadSubmission() + if (isNil(submission)) { + return this.response.status(404).json({ + message: "Submission not found", + }) + } + + const policy = this.buildPolicy(submission) + if (!policy.destroy()) { + return this.response.status(403).json({ + message: "You are not authorized to delete this submission", + }) + } + + await submission.destroy() + return this.response.status(204).send() + } catch (error) { + logger.error(`Error deleting submission ${error}`, { error }) + return this.response.status(422).json({ + message: `Error deleting submission: ${error}`, + }) + } + } + + private loadSource() { + return Source.findByPk(this.params.sourceId) + } + + private buildSourcePolicy(source: Source) { + return new SourcePolicy(this.currentUser, source) + } + + private loadSubmission() { + return Submission.findByPk(this.params.submissionId, { + include: [ + { + association: "source", + attributes: [], + where: { + id: this.params.sourceId, + }, + }, + ], + }) + } + + private buildSubmission(source: Source) { + const submission = Submission.build({ + ...this.request.body, + sourceId: source.id, + }) + submission.source = source + return submission + } + + private buildPolicy(submission: Submission) { + return new SubmissionsPolicy(this.currentUser, submission) + } +} + +export default SubmissionsController diff --git a/api/src/db/migrations/20251028211732_create-submissions-table.ts b/api/src/db/migrations/20251028211732_create-submissions-table.ts new file mode 100644 index 0000000..7e6caeb --- /dev/null +++ b/api/src/db/migrations/20251028211732_create-submissions-table.ts @@ -0,0 +1,35 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("submissions", (table) => { + table.increments("id").primary() + table.integer("source_id").notNullable() + table.integer("archive_item_id").nullable() + + table.string("referrer_ip_address").notNullable() + table.string("status", 50).notNullable().defaultTo("pending") + table.text("error_message").nullable() + + table.json("input_data").notNullable() + table.json("processed_data").nullable() + table.json("output_data").nullable() + + table.specificType("processed_at", "DATETIME2(0)") + table + .specificType("created_at", "DATETIME2(0)") + .notNullable() + .defaultTo(knex.raw("GETUTCDATE()")) + table + .specificType("updated_at", "DATETIME2(0)") + .notNullable() + .defaultTo(knex.raw("GETUTCDATE()")) + table.specificType("deleted_at", "DATETIME2(0)") + + table.foreign("source_id").references("sources.id") + table.foreign("archive_item_id").references("archive_items.id").onDelete("SET NULL") + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable("submissions") +} diff --git a/api/src/models/archive-item-audit.ts b/api/src/models/archive-item-audit.ts index 2029136..7c73341 100644 --- a/api/src/models/archive-item-audit.ts +++ b/api/src/models/archive-item-audit.ts @@ -15,10 +15,10 @@ import { PrimaryKey, } from "@sequelize/core/decorators-legacy" +import ArchiveItem from "@/models/archive-item" +import ArchiveItemFile from "@/models/archive-item-file" import BaseModel from "@/models/base-model" -import ArchiveItem from "./archive-item" -import ArchiveItemFile from "./archive-item-file" -import User from "./user" +import User from "@/models/user" export class ArchiveItemAudit extends BaseModel< InferAttributes, diff --git a/api/src/models/archive-item-file.ts b/api/src/models/archive-item-file.ts index 57b78f4..2391dce 100644 --- a/api/src/models/archive-item-file.ts +++ b/api/src/models/archive-item-file.ts @@ -15,8 +15,8 @@ import { PrimaryKey, } from "@sequelize/core/decorators-legacy" +import ArchiveItem from "@/models/archive-item" import BaseModel from "@/models/base-model" -import ArchiveItem from "./archive-item" export class ArchiveItemFile extends BaseModel< InferAttributes, diff --git a/api/src/models/archive-item.ts b/api/src/models/archive-item.ts index 288fcb1..cc8e98a 100644 --- a/api/src/models/archive-item.ts +++ b/api/src/models/archive-item.ts @@ -19,12 +19,12 @@ import { } from "@sequelize/core/decorators-legacy" import { isEmpty, isNil } from "lodash" -import BaseModel from "@/models/base-model" +import ArchiveItemCategory from "@/models/archive-item-category" import ArchiveItemFile from "@/models/archive-item-file" -import Category from "./category" -import ArchiveItemCategory from "./archive-item-category" -import User from "./user" -import Source from "./source"; +import BaseModel from "@/models/base-model" +import Category from "@/models/category" +import Source from "@/models/source" +import User from "@/models/user" /** Keep in sync with web/src/api/users-api.ts */ export enum SecurityLevel { diff --git a/api/src/models/category.ts b/api/src/models/category.ts index d85b991..61f4b54 100644 --- a/api/src/models/category.ts +++ b/api/src/models/category.ts @@ -17,10 +17,10 @@ import { PrimaryKey, } from "@sequelize/core/decorators-legacy" +import ArchiveItem from "@/models/archive-item" +import ArchiveItemCategory from "@/models/archive-item-category" import BaseModel from "@/models/base-model" -import Retention from "./retention" -import ArchiveItem from "./archive-item" -import ArchiveItemCategory from "./archive-item-category" +import Retention from "@/models/retention" export class Category extends BaseModel< InferAttributes, diff --git a/api/src/models/index.ts b/api/src/models/index.ts index 644e35b..e13ebbb 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -1,53 +1,57 @@ import db from "@/db/db-client" // Models -import User from "@/models/user" -import UserPermission from "@/models/user-permission" -import Source from "@/models/source" -import Retention from "@/models/retention" -import Category from "@/models/category" import ArchiveItem from "@/models/archive-item" import ArchiveItemAudit from "./archive-item-audit" import ArchiveItemCategory from "@/models/archive-item-category" import ArchiveItemFile from "@/models/archive-item-file" +import Category from "@/models/category" +import Retention from "@/models/retention" +import Source from "@/models/source" import SourceCategory from "@/models/source-category" +import Submission from "@/models/submission" +import User from "@/models/user" +import UserPermission from "@/models/user-permission" db.addModels([ - User, - UserPermission, ArchiveItem, - Source, - Retention, - Category, ArchiveItemAudit, ArchiveItemCategory, ArchiveItemFile, + Category, + Retention, + Source, SourceCategory, + Submission, + User, + UserPermission, ]) // Lazy load scopes -User.establishScopes() -UserPermission.establishScopes() ArchiveItem.establishScopes() -Source.establishScopes() -Retention.establishScopes() -Category.establishScopes() -ArchiveItemCategory.establishScopes() ArchiveItemAudit.establishScopes() +ArchiveItemCategory.establishScopes() ArchiveItemFile.establishScopes() +Category.establishScopes() +Retention.establishScopes() +Source.establishScopes() SourceCategory.establishScopes() +Submission.establishScopes() +User.establishScopes() +UserPermission.establishScopes() export { - User, - UserPermission, - Source, ArchiveItem, - Retention, - Category, - ArchiveItemCategory, ArchiveItemAudit, + ArchiveItemCategory, ArchiveItemFile, + Category, + Retention, + Source, SourceCategory, + Submission, + User, + UserPermission, } // Special db instance will all models loaded diff --git a/api/src/models/submission.ts b/api/src/models/submission.ts new file mode 100644 index 0000000..4782539 --- /dev/null +++ b/api/src/models/submission.ts @@ -0,0 +1,116 @@ +import { + DataTypes, + sql, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type NonAttribute, +} from "@sequelize/core" +import { + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, + ValidateAttribute, +} from "@sequelize/core/decorators-legacy" + +import BaseModel from "@/models/base-model" +import Source from "@/models/source" +import ArchiveItem from "@/models/archive-item" + +export enum SubmissionStatuses { + PENDING = "pending", + PROCESSING = "processing", + COMPLETED = "completed", + FAILED = "failed", +} + +export class Submission extends BaseModel< + InferAttributes, + InferCreationAttributes +> { + static readonly Statuses = SubmissionStatuses + + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @Attribute(DataTypes.INTEGER) + @NotNull + declare sourceId: number + + @Attribute(DataTypes.INTEGER) + declare archiveItemId: number | null + + @Attribute(DataTypes.STRING(45)) + @NotNull + declare referrerIpAddress: string + + @Attribute(DataTypes.STRING(50)) + @NotNull + @Default("pending") + @ValidateAttribute({ + isIn: { + args: [Object.values(SubmissionStatuses)], + msg: `Status must be one of ${Object.values(SubmissionStatuses).join(", ")}`, + }, + }) + declare status: CreationOptional + + @Attribute(DataTypes.TEXT) + declare errorMessage: string | null + + @Attribute(DataTypes.JSON) + @NotNull + declare inputData: Record + + @Attribute(DataTypes.JSON) + declare processedData: Record | null + + @Attribute(DataTypes.JSON) + declare outputData: Record | null + + @Attribute(DataTypes.DATE) + declare processedAt: Date | null + + @Attribute(DataTypes.DATE) + @NotNull + @Default(sql.fn("getutcdate")) + declare createdAt: CreationOptional + + @Attribute(DataTypes.DATE) + @NotNull + @Default(sql.fn("getutcdate")) + declare updatedAt: CreationOptional + + @Attribute(DataTypes.DATE) + declare deletedAt: Date | null + + // Associations + @BelongsTo(() => Source, { + foreignKey: "sourceId", + inverse: { + as: "submissions", + type: "hasMany", + }, + }) + declare source?: NonAttribute + + @BelongsTo(() => ArchiveItem, { + foreignKey: "archiveItemId", + inverse: { + as: "submissions", + type: "hasMany", + }, + }) + declare archiveItem?: NonAttribute + + static establishScopes() { + // add as needed + } +} + +export default Submission diff --git a/api/src/models/user.ts b/api/src/models/user.ts index b655fe0..e199ee8 100644 --- a/api/src/models/user.ts +++ b/api/src/models/user.ts @@ -19,7 +19,7 @@ import { import { isNil } from "lodash" import BaseModel from "@/models/base-model" -import UserPermission from "./user-permission" +import UserPermission from "@/models/user-permission" /** Keep in sync with web/src/api/users-api.ts */ export enum UserRoles { diff --git a/api/src/policies/archive-item-audits-policy.ts b/api/src/policies/archive-item-audits-policy.ts index 21fb749..0309cc7 100644 --- a/api/src/policies/archive-item-audits-policy.ts +++ b/api/src/policies/archive-item-audits-policy.ts @@ -7,10 +7,10 @@ import { isUndefined } from "lodash" export class ArchiveItemAuditsPolicy extends PolicyFactory(ArchiveItemAudit) { show(): boolean { - if (this.users.some((user) => user.id === this.user?.id)) { + if (this.users.some((user) => user.id === this.user.id)) { return true } - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } diff --git a/api/src/policies/archive-items-policy.ts b/api/src/policies/archive-items-policy.ts index 75d84ed..5a18e99 100644 --- a/api/src/policies/archive-items-policy.ts +++ b/api/src/policies/archive-items-policy.ts @@ -7,7 +7,7 @@ import { isUndefined } from "lodash" export class ArchiveItemsPolicy extends PolicyFactory(ArchiveItem) { show(): boolean { - if (this.users.some((user) => user.id === this.user?.id)) { + if (this.users.some((user) => user.id === this.user.id)) { return true } diff --git a/api/src/policies/base-policy.ts b/api/src/policies/base-policy.ts index ee5b98e..476793b 100644 --- a/api/src/policies/base-policy.ts +++ b/api/src/policies/base-policy.ts @@ -1,25 +1,20 @@ -import { ModelStatic, Model, Attributes, FindOptions, ScopeOptions, literal } from "@sequelize/core" +import { ModelStatic, Model, Attributes, FindOptions, ScopeOptions, sql } from "@sequelize/core" -import { Source, User } from "@/models" +import { User } from "@/models" import { Path, deepPick } from "@/utils/deep-pick" export type Actions = "show" | "create" | "update" | "destroy" -export const noRecordsScope = { where: literal("1 = 0") } -export const allRecordsScope = {} +export const NO_RECORDS_SCOPE = Object.freeze({ where: sql.literal("1 = 0") }) +export const ALL_RECORDS_SCOPE = Object.freeze({}) /** * See PolicyFactory below for policy with scope helpers */ export class BasePolicy { - protected user: User | null - protected source: Source | null - protected record: M - - constructor(creator: User | Source, record: M) { - this.user = creator instanceof User ? creator : null - this.source = creator instanceof Source ? creator : null - this.record = record - } + constructor( + protected user: User, + protected record: M + ) {} show(): boolean { return false diff --git a/api/src/policies/category-policy.ts b/api/src/policies/category-policy.ts index b870d59..c8f9959 100644 --- a/api/src/policies/category-policy.ts +++ b/api/src/policies/category-policy.ts @@ -10,17 +10,17 @@ export class CategoryPolicy extends PolicyFactory(Category) { } create(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } update(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } destroy(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } diff --git a/api/src/policies/decision-policy.ts b/api/src/policies/decision-policy.ts index 93f2136..564962c 100644 --- a/api/src/policies/decision-policy.ts +++ b/api/src/policies/decision-policy.ts @@ -7,7 +7,7 @@ import { isUndefined } from "lodash" export class DecisionPolicy extends PolicyFactory(ArchiveItem) { show(): boolean { - if (this.users.some((user) => user.id === this.user?.id)) { + if (this.users.some((user) => user.id === this.user.id)) { return true } diff --git a/api/src/policies/index.ts b/api/src/policies/index.ts index da693b3..747d565 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -2,11 +2,12 @@ // e.g. export * as Users from "./users" export { type BaseScopeOptions } from "./base-policy" -export { UsersPolicy } from "./users-policy" -export { ArchiveItemsPolicy } from "./archive-items-policy" export { ArchiveItemAuditsPolicy } from "./archive-item-audits-policy" -export { IntegrationsPolicy } from "./integrations-policy" -export { SourcePolicy } from "./source-policy" -export { RetentionPolicy } from "./retention-policy" +export { ArchiveItemsPolicy } from "./archive-items-policy" export { CategoryPolicy } from "./category-policy" export { DecisionPolicy } from "./decision-policy" +// export { IntegrationsPolicy } from "./integrations-policy" +export { RetentionPolicy } from "./retention-policy" +export { SourcePolicy } from "./source-policy" +export { SubmissionsPolicy } from "./submissions-policy" +export { UsersPolicy } from "./users-policy" diff --git a/api/src/policies/integrations-policy.ts b/api/src/policies/integrations-policy.ts index 6833277..a07a57d 100644 --- a/api/src/policies/integrations-policy.ts +++ b/api/src/policies/integrations-policy.ts @@ -1,57 +1,57 @@ -import { Attributes, FindOptions } from "@sequelize/core" - -import { Path } from "@/utils/deep-pick" -import { ArchiveItem, User } from "@/models" -import { PolicyFactory } from "@/policies/base-policy" -import { isNil, isUndefined } from "lodash" - -export class IntegrationsPolicy extends PolicyFactory(ArchiveItem) { - show(): boolean { - return false - } - - create(): boolean { - if (isNil(this.source)) return false - - return true - } - - update(): boolean { - return false - } - - destroy(): boolean { - return false - } - - permittedAttributes(): Path[] { - const attributes: (keyof Attributes)[] = [ - "title", - "description", - "decisionText", - "isDecision", - "summary", - "securityLevel", - "tags", - "submittedAt", - ] - return attributes - } - - permittedAttributesForCreate(): Path[] { - return [...this.permittedAttributes()] - } - - static policyScope(_user: User): FindOptions> { - return {} - } - private get users(): User[] { - if (isUndefined(this.record.users)) { - throw new Error("Expected record to have a users association") - } - - return this.record.users - } -} - -export default IntegrationsPolicy +// import { Attributes, FindOptions } from "@sequelize/core" + +// import { Path } from "@/utils/deep-pick" +// import { ArchiveItem, User } from "@/models" +// import { PolicyFactory } from "@/policies/base-policy" +// import { isNil, isUndefined } from "lodash" + +// export class IntegrationsPolicy extends PolicyFactory(ArchiveItem) { +// show(): boolean { +// return false +// } + +// create(): boolean { +// if (isNil(this.source)) return false + +// return true +// } + +// update(): boolean { +// return false +// } + +// destroy(): boolean { +// return false +// } + +// permittedAttributes(): Path[] { +// const attributes: (keyof Attributes)[] = [ +// "title", +// "description", +// "decisionText", +// "isDecision", +// "summary", +// "securityLevel", +// "tags", +// "submittedAt", +// ] +// return attributes +// } + +// permittedAttributesForCreate(): Path[] { +// return [...this.permittedAttributes()] +// } + +// static policyScope(_user: User): FindOptions> { +// return {} +// } +// private get users(): User[] { +// if (isUndefined(this.record.users)) { +// throw new Error("Expected record to have a users association") +// } + +// return this.record.users +// } +// } + +// export default IntegrationsPolicy diff --git a/api/src/policies/retention-policy.ts b/api/src/policies/retention-policy.ts index 55f85c4..35c6731 100644 --- a/api/src/policies/retention-policy.ts +++ b/api/src/policies/retention-policy.ts @@ -10,17 +10,17 @@ export class RetentionPolicy extends PolicyFactory(Retention) { } create(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } update(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } destroy(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } diff --git a/api/src/policies/source-policy.ts b/api/src/policies/source-policy.ts index adae90c..fe45a42 100644 --- a/api/src/policies/source-policy.ts +++ b/api/src/policies/source-policy.ts @@ -10,17 +10,17 @@ export class SourcePolicy extends PolicyFactory(Source) { } create(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } update(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } destroy(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } diff --git a/api/src/policies/submissions-policy.ts b/api/src/policies/submissions-policy.ts new file mode 100644 index 0000000..79bf7b9 --- /dev/null +++ b/api/src/policies/submissions-policy.ts @@ -0,0 +1,59 @@ +import { Attributes, FindOptions } from "@sequelize/core" + +import { Path } from "@/utils/deep-pick" +import { Submission, User } from "@/models" +import { ALL_RECORDS_SCOPE, NO_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy" + +export class SubmissionsPolicy extends PolicyFactory(Submission) { + show(): boolean { + if (this.user.isSystemAdmin) return true + + return false + } + + create(): boolean { + return true + } + + update(): boolean { + if (this.user.isSystemAdmin) return true + + return false + } + + destroy(): boolean { + if (this.user.isSystemAdmin) return true + + return false + } + + permittedAttributes(): Path[] { + const attributes: (keyof Attributes)[] = [ + "status", + "processedData", + "outputData", + "processedAt", + "errorMessage", + ] + + return attributes + } + + permittedAttributesForCreate(): Path[] { + return [ + "sourceId", + "archiveItemId", + "referrerIpAddress", + "inputData", + ...this.permittedAttributes(), + ] + } + + static policyScope(user: User): FindOptions> { + if (user.isSystemAdmin) return ALL_RECORDS_SCOPE + + return NO_RECORDS_SCOPE + } +} + +export default SubmissionsPolicy diff --git a/api/src/policies/users-policy.ts b/api/src/policies/users-policy.ts index d4f3a1f..671f26e 100644 --- a/api/src/policies/users-policy.ts +++ b/api/src/policies/users-policy.ts @@ -10,20 +10,20 @@ export class UsersPolicy extends PolicyFactory(User) { } create(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } update(): boolean { - if (this.user?.isSystemAdmin) return true - if (this.user?.id === this.record.id) return true + if (this.user.isSystemAdmin) return true + if (this.user.id === this.record.id) return true return false } destroy(): boolean { - if (this.user?.isSystemAdmin) return true + if (this.user.isSystemAdmin) return true return false } @@ -40,7 +40,7 @@ export class UsersPolicy extends PolicyFactory(User) { "unit", ] - if (this.user?.isSystemAdmin) { + if (this.user.isSystemAdmin) { attributes.push("email", "roles", "deactivatedAt") } diff --git a/api/src/router.ts b/api/src/router.ts index e149e6c..d3a5ac3 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -24,8 +24,9 @@ import { CategoriesController, CurrentUserController, DecisionsController, - IntegrationController, + // IntegrationController, RetentionsController, + Sources, SourcesController, UsersController, } from "@/controllers" @@ -42,7 +43,7 @@ router.route("/_status").get((_req: Request, res: Response) => { router.use("/migrate", migrator.migrationRouter) -router.route("/api/integrations/:sourceId").post(IntegrationController.create) +// router.route("/api/integrations/:sourceId").post(IntegrationController.create) // api routes router.use("/api", jwtMiddleware, ensureAndAuthorizeCurrentUser) @@ -63,6 +64,16 @@ router .patch(SourcesController.update) .delete(SourcesController.destroy) +router + .route("/api/sources/:sourceId/submissions") + .get(Sources.SubmissionsController.index) + .post(Sources.SubmissionsController.create) +router + .route("/api/sources/:sourceId/submissions/:submissionId") + .get(Sources.SubmissionsController.show) + .patch(Sources.SubmissionsController.update) + .delete(Sources.SubmissionsController.destroy) + router.route("/api/retentions").get(RetentionsController.index).post(RetentionsController.create) router .route("/api/retentions/:id") diff --git a/api/src/serializers/index.ts b/api/src/serializers/index.ts index b896e3b..142c03d 100644 --- a/api/src/serializers/index.ts +++ b/api/src/serializers/index.ts @@ -1,5 +1,21 @@ // Bundled exports -export * as Users from "./users" -export * as ArchiveItems from "./archive-items" -export * as Source from "./sources" -export * as Retention from "./retentions" +import * as ArchiveItems from "./archive-items" +import * as Retention from "./retentions" +import * as Source from "./sources" +import * as Users from "./users" + +export { + // avoid prettier wrap + ArchiveItems, + Retention, + Source, + Users, +} + +export default { + // avoid prettier wrap + ArchiveItems, + Retention, + Source, + Users, +} diff --git a/api/src/serializers/submissions/index-serializer.ts b/api/src/serializers/submissions/index-serializer.ts new file mode 100644 index 0000000..c4322a4 --- /dev/null +++ b/api/src/serializers/submissions/index-serializer.ts @@ -0,0 +1,35 @@ +import { pick } from "lodash" + +import { Submission } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type SubmissionAsIndex = Pick< + Submission, + | "id" + | "sourceId" + | "archiveItemId" + | "status" + | "submittedAt" + | "processedAt" + | "createdAt" + | "updatedAt" +> + +export class IndexSerializer extends BaseSerializer { + perform(): SubmissionAsIndex { + return { + ...pick(this.record, [ + "id", + "sourceId", + "archiveItemId", + "status", + "submittedAt", + "processedAt", + "createdAt", + "updatedAt", + ]), + } + } +} + +export default IndexSerializer diff --git a/api/src/serializers/submissions/index.ts b/api/src/serializers/submissions/index.ts new file mode 100644 index 0000000..7469f39 --- /dev/null +++ b/api/src/serializers/submissions/index.ts @@ -0,0 +1,2 @@ +export { IndexSerializer, type SubmissionAsIndex } from "./index-serializer" +export { ShowSerializer, type SubmissionAsShow } from "./show-serializer" diff --git a/api/src/serializers/submissions/show-serializer.ts b/api/src/serializers/submissions/show-serializer.ts new file mode 100644 index 0000000..e043785 --- /dev/null +++ b/api/src/serializers/submissions/show-serializer.ts @@ -0,0 +1,45 @@ +import { pick } from "lodash" + +import { Submission } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type SubmissionAsShow = Pick< + Submission, + | "id" + | "sourceId" + | "archiveItemId" + | "referrerIpAddress" + | "status" + | "errorMessage" + | "inputData" + | "processedData" + | "outputData" + | "submittedAt" + | "processedAt" + | "createdAt" + | "updatedAt" +> + +export class ShowSerializer extends BaseSerializer { + perform(): SubmissionAsShow { + return { + ...pick(this.record, [ + "id", + "sourceId", + "archiveItemId", + "referrerIpAddress", + "status", + "errorMessage", + "inputData", + "processedData", + "outputData", + "submittedAt", + "processedAt", + "createdAt", + "updatedAt", + ]), + } + } +} + +export default ShowSerializer diff --git a/api/src/services/index.ts b/api/src/services/index.ts index accc87f..2a1c6f4 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -1,5 +1,26 @@ -export * as Users from "./users" -export * as Sources from "./sources" -export * as Retentions from "./retentions" -export * as Categories from "./categories" +// TODO: update unsafe legacy exports to new style export * from "./file-storage-service" + +// New style exports +import * as Categories from "./categories" +import * as Retentions from "./retentions" +import * as Sources from "./sources" +import * as Submissions from "./submissions" +import * as Users from "./users" + +export { + // avoid prettier wrap + Categories, + Retentions, + Sources, + Submissions, + Users, +} + +export default { + Categories, + Retentions, + Sources, + Submissions, + Users, +} diff --git a/api/src/services/submissions/create-service.ts b/api/src/services/submissions/create-service.ts new file mode 100644 index 0000000..4286b99 --- /dev/null +++ b/api/src/services/submissions/create-service.ts @@ -0,0 +1,43 @@ +import { CreationAttributes } from "@sequelize/core" +import { isNil } from "lodash" + +import { Submission, User } from "@/models" +import BaseService from "@/services/base-service" + +export type SubmissionCreationAttributes = Partial> + +export class CreateService extends BaseService { + constructor( + private attributes: SubmissionCreationAttributes, + private currentUser: User + ) { + super() + } + + async perform(): Promise { + const { sourceId, referrerIpAddress, inputData, ...optionalAttributes } = this.attributes + + if (isNil(sourceId)) { + throw new Error("sourceId is required") + } + + if (isNil(referrerIpAddress)) { + throw new Error("referrerIpAddress is required") + } + + if (isNil(inputData)) { + throw new Error("inputData is required") + } + + const submission = await Submission.create({ + ...optionalAttributes, + sourceId, + referrerIpAddress, + inputData, + }) + + return submission + } +} + +export default CreateService diff --git a/api/src/services/submissions/index.ts b/api/src/services/submissions/index.ts new file mode 100644 index 0000000..a5af99e --- /dev/null +++ b/api/src/services/submissions/index.ts @@ -0,0 +1 @@ +export { CreateService, type SubmissionCreationAttributes } from "./create-service" diff --git a/bin/dev b/bin/dev index e24b4c4..0084256 100755 --- a/bin/dev +++ b/bin/dev @@ -11,6 +11,8 @@ class DevHelper "check-types" => :check_types, "branch-from" => :branch_from, "changes-from" => :changes_from, + "edit-pr" => :edit_pr, + "description-from" => :description_from, } METHOD_TO_COMMAND = COMMAND_TO_METHOD.invert @@ -197,6 +199,27 @@ class DevHelper system("git checkout -b #{branch_name}") end + ## + # Fetches the description of a GitHub issue and prints it to the console in markdown format. + # Example: + # dev description-from https://github.com/icefoganalytics/travel-authorization/issues/218 + # + # Produces: + # ... a bunch of markdown text ... + def description_from(github_issue_url, *args, **kwargs) + description = GithubApi.fetch_issue_body(github_issue_url) + puts description + end + + ## + # Edits the description of a pull request. + # Example: + # dev edit-pr https://github.com/icefoganalytics/travel-authorization/pull/218 + def edit_pr(pull_request_url, *args, **kwargs) + PullRequestEditor.edit_pull_request_description(pull_request_url, *args, **kwargs) + exit(0) + end + def changes_from(branch_or_ref_or_commit_hash = 'HEAD') system("git --no-pager log origin/main..#{branch_or_ref_or_commit_hash} --patch") end diff --git a/bin/github_api.rb b/bin/github_api.rb index b534e4c..833c3fc 100644 --- a/bin/github_api.rb +++ b/bin/github_api.rb @@ -1,6 +1,4 @@ -require 'net/http' -require 'json' -require 'uri' +require "open3" ## # Supports building a branch name from a Github issue URL @@ -9,22 +7,32 @@ # If issue is from a replated repo (presumably the upstream one), the branch name will be in the format: # -issue-/ class GithubApi - GITHUB_TOKEN = ENV['GITHUB_TOKEN'] - GITHUB_REPO = 'icefoganalytics/digital-vault' # Format: 'owner/repo' - GITHUB_API_BASE = 'https://api.github.com' + GITHUB_REPO = "icefoganalytics/digital-vault" # Format: 'owner/repo' def self.build_branch_name(github_issue_url) - if GITHUB_TOKEN.nil? - puts 'Please set GITHUB_TOKEN environment variable' - return - end - issue_repo = extract_issue_repo(github_issue_url) issue_number = extract_issue_number(github_issue_url) issue_title = fetch_issue_title(issue_repo, issue_number) format_branch_name(issue_repo, issue_number, issue_title) end + # Fetches the body of a GitHub issue using GitHub CLI + def self.fetch_issue_body(github_issue_url) + issue_repo = extract_issue_repo(github_issue_url) + issue_number = extract_issue_number(github_issue_url) + + command = "gh issue view #{issue_number} --repo #{issue_repo} --json body --jq .body" + puts "running: #{command}" + stdout, stderr, status = Open3.capture3(command) + + if status.success? + stdout.strip + else + puts "Error fetching issue body: #{stderr}" + exit(1) + end + end + private def self.extract_issue_repo(url) @@ -37,35 +45,25 @@ def self.extract_issue_number(url) end def self.fetch_issue_title(repo, issue_number) - puts "Fetching issue title for #{repo}##{issue_number}..." - uri = URI("#{GITHUB_API_BASE}/repos/#{repo}/issues/#{issue_number}") - request = Net::HTTP::Get.new(uri) - request['Authorization'] = "token #{GITHUB_TOKEN}" - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end + command = "gh issue view #{issue_number} --repo #{repo} --json title --jq .title" + puts "running: #{command}" + stdout, stderr, status = Open3.capture3(command) - if response.code.to_i == 404 || response.code.to_i == 401 - raise ScriptError, "Authorization failed. Please check your GitHub token." + if status.success? + stdout.strip + else + puts "Error fetching issue title: #{stderr}" + exit(1) end - - data = JSON.parse(response.body) - data['title'] end def self.format_branch_name(issue_repo, issue_number, issue_title) - formatted_title = issue_title.downcase - .strip - .gsub(/\s+/, '-') - .gsub(/[^a-z0-9\-]/, '') - .gsub(/-+/, '-') + formatted_title = + issue_title.downcase.strip.gsub(/\s+/, "-").gsub(/[^a-z0-9\-]/, "").gsub(/-+/, "-") - if issue_repo == GITHUB_REPO - return "issue-#{issue_number}/#{formatted_title}" - end + return "issue-#{issue_number}/#{formatted_title}" if issue_repo == GITHUB_REPO - issue_owner = issue_repo.split('/')[0] + issue_owner = issue_repo.split("/")[0] "#{issue_owner}-issue-#{issue_number}/#{formatted_title}" end end diff --git a/bin/pull-request-editor.rb b/bin/pull-request-editor.rb new file mode 100644 index 0000000..0769e36 --- /dev/null +++ b/bin/pull-request-editor.rb @@ -0,0 +1,74 @@ +require "tempfile" +require "open3" + +## +# Supports fetching and editing PR descriptions from a full GitHub PR URL using SSH and GitHub CLI. +# +# Example usage: +# - PullRequestEditor.edit_pull_request_description('https://github.com/icefoganalytics/travel-authorization/pull/218') +class PullRequestEditor + # Edits the pull request description using GitHub CLI and VS Code + def self.edit_pull_request_description(pull_request_url) + repo, pull_request_number = extract_repo_and_pull_request_number(pull_request_url) + + pull_request_body = fetch_pull_request_body(repo, pull_request_number) + + app_root = File.expand_path(File.join(File.dirname(__FILE__), "..")) + tmp_dir = File.join(app_root, "tmp") + Dir.mkdir(tmp_dir) unless Dir.exist?(tmp_dir) + + Tempfile.create(["pull_request_description_#{pull_request_number}", ".md"], tmp_dir) do |file| + file.write(pull_request_body) + file.flush + + system("windsurf --wait #{file.path}") + + updated_pull_request_body = File.read(file.path) + + if updated_pull_request_body.strip != pull_request_body.strip + update_pull_request_body(repo, pull_request_number, file.path) + else + puts "No changes made to the PR description." + end + end + end + + private + + # Extracts the repository name and PR number from a full GitHub PR URL + def self.extract_repo_and_pull_request_number(pull_request_url) + match_data = pull_request_url.match(%r{github.com/([^/]+)/([^/]+)/pull/(\d+)}) + repo = "#{match_data[1]}/#{match_data[2]}" + pull_request_number = match_data[3] + [repo, pull_request_number] + end + + # Fetches the PR body using the GitHub CLI + def self.fetch_pull_request_body(repo, pull_request_number) + command = "gh pr view #{pull_request_number} --repo #{repo} --json body --jq .body" + puts "running: #{command}" + stdout, stderr, status = Open3.capture3(command) + + if status.success? + stdout.strip + else + puts "Error fetching PR description: #{stderr}" + exit(1) + end + end + + # Updates the PR body using the GitHub CLI + def self.update_pull_request_body(repo, pull_request_number, new_body_file_path) + command = + "gh pr edit #{pull_request_number} --repo #{repo} --body-file \"#{new_body_file_path}\"" + puts "running: #{command}" + stdout, stderr, status = Open3.capture3(command) + + if status.success? + puts "stdout: #{stdout}" + puts "PR description updated successfully." + else + puts "Error updating PR description: #{stderr}" + end + end +end diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 6b16dba..99f7cae 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -34,6 +34,7 @@ services: environment: <<: *default-environment tty: true # allows attaching debugger, equivalent of docker exec -t + init: true # stdin_open: true # equivalent of docker exec -i ports: - "3000:3000" @@ -140,6 +141,7 @@ services: environment: <<: *default-environment tty: true # allows attaching debugger, equivalent of docker exec -t + init: true # stdin_open: true # equivalent of docker exec -i ports: - "5000:5000" diff --git a/web/src/plugins/vuetify-plugin.ts b/web/src/plugins/vuetify-plugin.ts index 7d3dc81..fd132d8 100644 --- a/web/src/plugins/vuetify-plugin.ts +++ b/web/src/plugins/vuetify-plugin.ts @@ -11,20 +11,11 @@ import "vuetify/styles" // Composables import { createVuetify } from "vuetify" -import * as components from "vuetify/components" -import * as directives from "vuetify/directives" -import * as labsComponents from "vuetify/labs/components" import darkTheme from "@/theme/DarkTheme" // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides export default createVuetify({ - components: { - ...components, - ...labsComponents, - }, - directives, - theme: { defaultTheme: "darkTheme", themes: { darkTheme }, diff --git a/web/vite.config.js b/web/vite.config.js index 222b83a..faad997 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,4 +1,4 @@ -import { fileURLToPath, URL } from 'node:url' +import { fileURLToPath, URL } from "node:url" // Plugins import vue from "@vitejs/plugin-vue" @@ -13,7 +13,9 @@ export default defineConfig({ vue(), // https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin vuetify({ - autoImport: true, + autoImport: { + labs: true, + }, }), ], build: { @@ -22,8 +24,7 @@ export default defineConfig({ define: { "process.env": {} }, resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - + "@": fileURLToPath(new URL("./src", import.meta.url)), }, extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"], },