From 5f5a9ddb3137306f462876c43255122eed059830 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 14:12:05 -0700 Subject: [PATCH 01/17] :hammer: update developer tooling to latest dev command helpers. I should really build a module system so I can do stuff like dev upgrade or dev add edit-pr --- bin/dev | 23 ++++++++++++ bin/github_api.rb | 64 ++++++++++++++++----------------- bin/pull-request-editor.rb | 74 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 bin/pull-request-editor.rb 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 From 89f43a84ceb54350979f15b5cafadec6e80c18df Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 14:14:11 -0700 Subject: [PATCH 02/17] :broom: Remove unused import from integration controller. --- api/src/controllers/integration-controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/controllers/integration-controller.ts b/api/src/controllers/integration-controller.ts index 3666baf..fe78981 100644 --- a/api/src/controllers/integration-controller.ts +++ b/api/src/controllers/integration-controller.ts @@ -1,5 +1,5 @@ import logger from "@/utils/logger" -import { isArray, isNil, uniqBy } from "lodash" +import { isNil, uniqBy } from "lodash" import { ArchiveItem, ArchiveItemAudit, Category, Source, User, UserPermission } from "@/models" import { IntegrationsPolicy } from "@/policies" import BaseController from "@/controllers/base-controller" @@ -70,7 +70,6 @@ export class IntegrationController extends BaseController { let expireAction = "" for (const retention of retentionOptions) { - console.log(retention) if (retention.retentionDate) { From b3bcdfd0a41a39d88b786af0c98ea086fcc86f52 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 14:52:38 -0700 Subject: [PATCH 03/17] :see_no_evil: Hide claude code local configuration file. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 4ccefc4e47fbf37bd21277a45b5a3e8c205bb5d2 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 14:58:55 -0700 Subject: [PATCH 04/17] :butterfly: Add submissions table to track per source submissions. Model developed from current proof of concept in https://github.com/icefoganalytics/digital-vault/blob/17c091c8d2d5be3e31b093158cf45786a16e1ac2/api/src/controllers/integration-controller.ts --- ...20251028211732_create-submissions-table.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 api/src/db/migrations/20251028211732_create-submissions-table.ts 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") +} From 690289914cc9fd4f96fcc21ee4afb327342cf821 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:10:03 -0700 Subject: [PATCH 05/17] :hammer: Add AGENTS.md file. Very much a work in progress so update as needed. Take from https://github.com/icefoganalytics/wrap/blob/ea184b2d824cbfe312be8e23cd664877b4e6a017/AGENTS.md --- AGENTS.md | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 AGENTS.md 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) + }) + }) + }) +}) +``` From 972ccfa5343d0bb7bd7f7b225eda9c07d4118074 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:16:37 -0700 Subject: [PATCH 06/17] :abc: Alphabetize model exports. Why? Easier to find and avoid duplicate exports. --- api/src/models/index.ts | 44 ++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/api/src/models/index.ts b/api/src/models/index.ts index 644e35b..d1b8c2f 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -1,53 +1,53 @@ 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 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, + 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() +User.establishScopes() +UserPermission.establishScopes() export { - User, - UserPermission, - Source, ArchiveItem, - Retention, - Category, - ArchiveItemCategory, ArchiveItemAudit, + ArchiveItemCategory, ArchiveItemFile, + Category, + Retention, + Source, SourceCategory, + User, + UserPermission, } // Special db instance will all models loaded From 7a12b6188bb016971fd93c023f49c20172f84ec9 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:19:11 -0700 Subject: [PATCH 07/17] :ok_hand: standardize model import pattern. Always use absolute import to avoid import loops. --- api/src/models/archive-item-audit.ts | 6 +++--- api/src/models/archive-item-file.ts | 2 +- api/src/models/archive-item.ts | 10 +++++----- api/src/models/category.ts | 6 +++--- api/src/models/user.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) 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/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 { From 084e7f2ae54dc7306ffd68abff65ad6a3bf377f2 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:43:55 -0700 Subject: [PATCH 08/17] :sparkles: Add submission model to track submissions per source. --- api/src/models/index.ts | 4 ++ api/src/models/submission.ts | 116 +++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 api/src/models/submission.ts diff --git a/api/src/models/index.ts b/api/src/models/index.ts index d1b8c2f..e13ebbb 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -9,6 +9,7 @@ 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" @@ -21,6 +22,7 @@ db.addModels([ Retention, Source, SourceCategory, + Submission, User, UserPermission, ]) @@ -34,6 +36,7 @@ Category.establishScopes() Retention.establishScopes() Source.establishScopes() SourceCategory.establishScopes() +Submission.establishScopes() User.establishScopes() UserPermission.establishScopes() @@ -46,6 +49,7 @@ export { Retention, Source, SourceCategory, + Submission, User, UserPermission, } 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 From 0cbe5cd1003b5c1e0344555dac2269aad41a87cc Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:22:47 -0700 Subject: [PATCH 09/17] :sparkles: Add ordering support to base controller. Code taken from https://github.com/icefoganalytics/wrap/blob/ea184b2d824cbfe312be8e23cd664877b4e6a017/api/src/controllers/base-controller.ts --- api/src/controllers/base-controller.ts | 33 ++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 From d9d4658eca7bf584d917ac2516bbb93dd555c6cb Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:45:05 -0700 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=94=A8=20Add=20init:=20true=20to=20?= =?UTF-8?q?core=20services=20so=20they=20accept=20SIGINT=20and=20SIGTERM.?= =?UTF-8?q?=20I.e.=20so=20you=20can=20kill=20them=20quickly=20instead=20of?= =?UTF-8?q?=20having=20to=20wait=20for=20the=2010s=20timeout.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://docs.docker.com/reference/compose-file/services/#init Code taken from https://github.com/icefoganalytics/wrap/commit/e807713740fa9769a5f90923d599bcbf27848adf --- docker-compose.development.yml | 2 ++ 1 file changed, 2 insertions(+) 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" From a05928d083db9def210e419e98d68ae0f0b4f8cd Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:48:28 -0700 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=90=9B=20Fix=20vuetify=20build=20an?= =?UTF-8?q?d=20import=20via=20automatic=20treeshaking.=20See=20https://vue?= =?UTF-8?q?tifyjs.com/en/features/treeshaking/=20NOTE:=20this=20fixes=20th?= =?UTF-8?q?e=20double=20load=20bug=20on=20all=20routes=20on=20first=20load?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code taken from https://github.com/icefoganalytics/wrap/commit/ea5fd720a19b08cac88270b9e31dc92b915d366a --- web/src/plugins/vuetify-plugin.ts | 9 --------- web/vite.config.js | 9 +++++---- 2 files changed, 5 insertions(+), 13 deletions(-) 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"], }, From 0d4007142da5c7a94bc8534ee785482ae0db5586 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 15:54:57 -0700 Subject: [PATCH 12/17] :art: Clean up and alphabetize exports. --- api/src/policies/index.ts | 10 +++++----- api/src/serializers/index.ts | 24 ++++++++++++++++++++---- api/src/services/index.ts | 26 ++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/api/src/policies/index.ts b/api/src/policies/index.ts index da693b3..954ccb9 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -2,11 +2,11 @@ // 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 { UsersPolicy } from "./users-policy" 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/services/index.ts b/api/src/services/index.ts index accc87f..885ced9 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -1,5 +1,23 @@ -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 Users from "./users" + +export { + // avoid prettier wrap + Categories, + Retentions, + Sources, + Users, +} + +export default { + Categories, + Retentions, + Sources, + Users, +} From 3fcf5f02f10e505ea20c254c8f269a32c49ebe78 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 16:01:02 -0700 Subject: [PATCH 13/17] :recycle: Standardize base policy. Policy logic should be as simple as possible to avoid access loopholes. --- api/src/policies/base-policy.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) 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 From 66e9435ae462f694fcd6686962b6b9190bf334e6 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 16:07:14 -0700 Subject: [PATCH 14/17] :construction: Comment out integration controller as we are replacing it. Will replace with sources/:sourceId/submissions setup. TODO: delete all reference once they have been recreated in Submissions.CreateService. --- api/src/controllers/index.ts | 2 +- api/src/controllers/integration-controller.ts | 319 +++++++++--------- api/src/policies/index.ts | 2 +- api/src/policies/integrations-policy.ts | 114 +++---- api/src/router.ts | 4 +- 5 files changed, 221 insertions(+), 220 deletions(-) diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index f131e1f..2279135 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -3,7 +3,7 @@ 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 { IntegrationController } from "./integration-controller" export { UsersController } from "./users-controller" export { SourcesController } from "./sources-controller" export { RetentionsController } from "./retentions-controller" diff --git a/api/src/controllers/integration-controller.ts b/api/src/controllers/integration-controller.ts index fe78981..9db694d 100644 --- a/api/src/controllers/integration-controller.ts +++ b/api/src/controllers/integration-controller.ts @@ -1,159 +1,160 @@ -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) - - 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/policies/index.ts b/api/src/policies/index.ts index 954ccb9..b6b38bd 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -6,7 +6,7 @@ export { ArchiveItemAuditsPolicy } from "./archive-item-audits-policy" export { ArchiveItemsPolicy } from "./archive-items-policy" export { CategoryPolicy } from "./category-policy" export { DecisionPolicy } from "./decision-policy" -export { IntegrationsPolicy } from "./integrations-policy" +// export { IntegrationsPolicy } from "./integrations-policy" export { RetentionPolicy } from "./retention-policy" export { SourcePolicy } from "./source-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/router.ts b/api/src/router.ts index e149e6c..3add847 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -24,7 +24,7 @@ import { CategoriesController, CurrentUserController, DecisionsController, - IntegrationController, + // IntegrationController, RetentionsController, SourcesController, UsersController, @@ -42,7 +42,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) From 76397528ae97e23429b2f5d90a5db267b53f4f7e Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 16:09:55 -0700 Subject: [PATCH 15/17] :art: Simplify policy logic now that user is a required param. --- api/src/policies/archive-item-audits-policy.ts | 4 ++-- api/src/policies/archive-items-policy.ts | 2 +- api/src/policies/category-policy.ts | 6 +++--- api/src/policies/decision-policy.ts | 2 +- api/src/policies/retention-policy.ts | 6 +++--- api/src/policies/source-policy.ts | 6 +++--- api/src/policies/users-policy.ts | 10 +++++----- 7 files changed, 18 insertions(+), 18 deletions(-) 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/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/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/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") } From b5b5b4049aa288219f7eadc3357cffb6350c72cd Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 16:14:17 -0700 Subject: [PATCH 16/17] :abc: Alphabetize controller exports. Why? Easier to find and and avoid duplicates. --- api/src/controllers/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index 2279135..c9b883c 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -1,11 +1,11 @@ // Controllers -export { ArchiveItemsController } from "./archive-items-controller" -export { ArchiveItemFilesController } from "./archive-item-files-controller" export { ArchiveItemAuditsController } from "./archive-item-audits-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 { UsersController } from "./users-controller" -export { SourcesController } from "./sources-controller" export { RetentionsController } from "./retentions-controller" -export { CategoriesController } from "./categories-controller" -export { DecisionsController } from "./decisions-controller" +export { SourcesController } from "./sources-controller" +export { UsersController } from "./users-controller" From a44a8e5c257bb74a25609fd7f94d6a1fe35e4d0a Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Tue, 28 Oct 2025 16:16:41 -0700 Subject: [PATCH 17/17] :construction: Add intial submissions controller with stub creation action. --- api/src/controllers/index.ts | 3 + api/src/controllers/sources/index.ts | 1 + .../sources/submissions-controller.ts | 231 ++++++++++++++++++ api/src/policies/index.ts | 1 + api/src/policies/submissions-policy.ts | 59 +++++ api/src/router.ts | 11 + .../submissions/index-serializer.ts | 35 +++ api/src/serializers/submissions/index.ts | 2 + .../submissions/show-serializer.ts | 45 ++++ api/src/services/index.ts | 3 + .../services/submissions/create-service.ts | 43 ++++ api/src/services/submissions/index.ts | 1 + 12 files changed, 435 insertions(+) create mode 100644 api/src/controllers/sources/index.ts create mode 100644 api/src/controllers/sources/submissions-controller.ts create mode 100644 api/src/policies/submissions-policy.ts create mode 100644 api/src/serializers/submissions/index-serializer.ts create mode 100644 api/src/serializers/submissions/index.ts create mode 100644 api/src/serializers/submissions/show-serializer.ts create mode 100644 api/src/services/submissions/create-service.ts create mode 100644 api/src/services/submissions/index.ts diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index c9b883c..38ec87b 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -9,3 +9,6 @@ export { DecisionsController } from "./decisions-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/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/policies/index.ts b/api/src/policies/index.ts index b6b38bd..747d565 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -9,4 +9,5 @@ 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/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/router.ts b/api/src/router.ts index 3add847..d3a5ac3 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -26,6 +26,7 @@ import { DecisionsController, // IntegrationController, RetentionsController, + Sources, SourcesController, UsersController, } from "@/controllers" @@ -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/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 885ced9..2a1c6f4 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -5,6 +5,7 @@ export * from "./file-storage-service" 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 { @@ -12,6 +13,7 @@ export { Categories, Retentions, Sources, + Submissions, Users, } @@ -19,5 +21,6 @@ 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"