diff --git a/AGENTS.md b/AGENTS.md index f174a449..361dfa53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -461,7 +461,9 @@ Multi-step guides that orchestrate templates. Use for complete features. - `create-admin-ui.md` - Full CRUD admin interface (references templates) - `pull-request-management.md` - Creating and editing PRs -- `jira-issue-creation.md` - Creating Jira issues in TK project +- `jira-issue-management.md` - Creating, enhancing, and managing Jira issues +- `code-review.md` - Code review quality control +- `testing-instructions.md` - Comprehensive testing instructions for PRs #### Usage Examples diff --git a/agents/README.md b/agents/README.md index c7e7e493..1128d745 100644 --- a/agents/README.md +++ b/agents/README.md @@ -12,7 +12,9 @@ agents/ │ └── frontend/ (api-client, composables, components, pages, searchable-autocomplete) ├── workflows/ (multi-step task guides) │ ├── create-admin-ui.md -│ ├── jira-issue-creation.md +│ ├── jira-issue-management.md +│ ├── code-review.md +│ ├── testing-instructions.md │ └── pull-request-management.md ├── plans/ (implementation plans for complex refactoring work) │ ├── README.md (planning guidelines and structure) @@ -58,7 +60,9 @@ Reusable code patterns organized by layer: Multi-step task guides: - [create-admin-ui.md](workflows/create-admin-ui.md) - Full CRUD admin UI (references templates) -- [jira-issue-creation.md](workflows/jira-issue-creation.md) - Create Jira issues in TK project +- [jira-issue-management.md](workflows/jira-issue-management.md) - Create, enhance, and manage Jira issues +- [code-review.md](workflows/code-review.md) - Code review quality control +- [testing-instructions.md](workflows/testing-instructions.md) - Comprehensive testing instructions for PRs - [pull-request-management.md](workflows/pull-request-management.md) - Create and manage PRs ### Plans (`agents/plans/`) diff --git a/agents/plans/README.md b/agents/plans/README.md index 568af6b2..935d35f2 100644 --- a/agents/plans/README.md +++ b/agents/plans/README.md @@ -4,9 +4,7 @@ This directory contains implementation planning documents for the Traditional Kn ## Available Plans -| Plan | Description | -|------|-------------| -| [Plan, Vue Component Expansion Panels Refactor, 2026-02-03](Plan, Vue Component Expansion Panels Refactor, 2026-02-03.md) | Refactor InformationSharingAgreementLayout to use named slots component instead of router-view | +See this directory for available plans. Plans are created as needed for complex implementation work and follow the naming convention: `Type, Title, Date.md`. ## Using Plans @@ -107,4 +105,4 @@ All plans follow this template: --- -**Last Updated:** 2026-02-03 +**Last Updated:** 2026-03-12 diff --git a/agents/workflows/README.md b/agents/workflows/README.md index b1683d83..f868fd5e 100644 --- a/agents/workflows/README.md +++ b/agents/workflows/README.md @@ -15,59 +15,22 @@ These workflows exist to ensure consistent, high-quality output from AI agents. ## Available Workflows -### [create-admin-ui.md](create-admin-ui.md) +| Workflow | Description | +|----------|-------------| +| [jira-issue-management.md](./jira-issue-management.md) | Create well-structured Jira issues following project patterns | +| [code-review.md](./code-review.md) | Code review quality control for TypeScript code | +| [pull-request-management.md](./pull-request-management.md) | Create and edit well-structured pull requests following project patterns | +| [testing-instructions.md](./testing-instructions.md) | Generate comprehensive testing instructions for pull requests | +| [create-admin-ui.md](./create-admin-ui.md) | Complete workflow for adding full CRUD admin UI for any model | -Complete workflow for adding full CRUD admin UI for any model. +### Complete PR Creation Sequence -**Uses templates from:** [`agents/templates/`](../templates/) +For a full PR workflow, follow these steps in order: -**Produces:** -- Backend: Model, Controller, Policy, Services, Serializers, Routes -- Frontend: API Client, Composables, Components, Pages - -**Key Features:** -- References reusable templates from `agents/templates/` -- Includes implementation order, checklists, common pitfalls -- Matches actual implementation from External Organizations - -**Reference:** External Organizations (`d4a9366`, `1f1dac8`) - ---- - -### [jira-issue-creation.md](jira-issue-creation.md) - -Complete workflow for creating well-structured Jira issues in the Traditional Knowledge (TK) project. - -**Includes:** -- Step-by-step guidance for bugs, stories, tasks, and epics -- Proper issue type selection and formatting -- Acceptance criteria and definition of done -- Priority and component assignment -- Quality checklists and best practices - -**Project URL:** https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 - ---- - -### [pull-request-management.md](pull-request-management.md) - -Complete workflow for creating and editing well-structured pull requests following Traditional Knowledge project patterns and conventions. - -**Includes:** -- PR title patterns (TICKET-ID, Fix:, Action Verb + Noun) -- Comprehensive PR body template with Context, Implementation, Screenshots, Testing Instructions -- Traditional Knowledge-specific testing commands and navigation patterns -- Quality checklist and common pitfalls -- Examples from actual Traditional Knowledge pull requests -- Complete guide for editing existing pull requests - -**Key Features:** -- Draft PR creation process -- Standardized testing instructions -- UI navigation patterns for Traditional Knowledge -- Code quality standards integration -- Common editing scenarios and workflows -- Step-by-step examples for updating PR content +1. **[jira-issue-management.md](./jira-issue-management.md)** — Create/update the Jira issue +2. **[code-review.md](./code-review.md)** — Review code quality before PR +3. **[pull-request-management.md](./pull-request-management.md)** — Create the draft PR +4. **[testing-instructions.md](./testing-instructions.md)** — Add comprehensive testing instructions --- @@ -81,20 +44,32 @@ Follow the workflow in agents/workflows/create-admin-ui.md to create admin UI for the KnowledgeCategory model. ``` -**Example - Specific Template:** +**Example - Create PR:** ``` -Follow the template in agents/templates/frontend/components.md -to create the KnowledgeCategoriesDataTable component. +Follow the workflow in agents/workflows/pull-request-management.md +to create a PR for my changes. +``` + +**Example - Testing Instructions:** +``` +Follow the workflow in agents/workflows/testing-instructions.md +to create testing instructions for this PR. ``` -**Example - Backend Only:** +**Example - Code Review:** ``` -Follow the backend templates in agents/templates/backend/ -to create the API for KnowledgeCategory. +Follow the workflow in agents/workflows/code-review.md +to review the code changes on this branch. +``` + +**Example - Specific Template:** +``` +Follow the template in agents/templates/frontend/components.md +to create the KnowledgeCategoriesDataTable component. ``` See parent [agents/README.md](../README.md) for setup instructions. --- -**Last Updated:** 2026-01-27 +**Last Updated:** 2026-03-12 diff --git a/agents/workflows/code-review.md b/agents/workflows/code-review.md new file mode 100644 index 00000000..30763c2a --- /dev/null +++ b/agents/workflows/code-review.md @@ -0,0 +1,114 @@ +--- +description: Code Review Quality Control Workflow for TypeScript code in the Traditional Knowledge project +--- + +# Code Review Quality Control Workflow + +Use this workflow to review TypeScript code for the Traditional Knowledge project. Apply every rule below as a hard gate — flag any violation as a blocking issue. For each issue found, quote the offending lines and state which rule they violate. + +**Complete workflow sequence:** This is step 2 of 4 in the complete PR creation process. Always use after jira-issue-management workflow and before pull-request-management workflow to ensure code quality standards are met. + +## Steps + +1. **Review TypeScript Strictness** + - No `any` — not in source, not in tests, not in casts + - No `!` (non-null assertion) — use `isNil` / optional chaining / explicit guard clauses instead + - No `@ts-ignore` or `@ts-expect-error` + - No relative imports — always `@/...` path aliases. Exception: barrel `index.ts` files may use relative imports for re-exports + +2. **Check Type Cast Placement** + - Type casts with `as` must happen on assignment or creation, never at the point of use + - **When a cast is needed at point of use, create a new type definition** that returns the correct type by design, eliminating the need for casts + - Prefer proper type definitions over runtime type checks; only use guard clauses when runtime validation is genuinely needed, not just to satisfy TypeScript + - A cast is only acceptable when the type system genuinely cannot infer a type that is known at runtime + +3. **Check Cyclomatic Complexity** + - Each function or method should have at most one level of conditional nesting in its main body + - Use guard clauses (early returns) to flatten logic + - Each guard clause must be followed by a blank line before the next statement + +4. **Verify Test Completeness** + - Every test must include explicit Arrange / Act / Assert comments and be fully self-contained + - No shared state from `beforeEach` unless it is truly invariant setup (e.g., DB reset) + - Service test assertions should target database state, not service return values, unless the return value is specifically what is under test + - Spy assertions — use `expect(spy).not.toHaveBeenCalled()` without arguments. Never use `not.toHaveBeenCalledWith(...)` + +5. **Ensure One Expect Per Test** + - Consolidate assertions into a single `expect` using `expect.objectContaining` + - Exceptions: tests that assert something did NOT happen (e.g., `not.toHaveBeenCalled()`, `not.toBe()`) may be standalone single-line expects + - When asserting "not any of N values", split into N separate tests rather than looping + +6. **Check Test Naming** + - Tests must follow `"when [condition], [expected behaviour]"` with full English words (no abbreviations) + - Numbered entities in test bodies: `user1`, `user2`, `workflowStep1`, `workflowStep2` — never `existingUser`, `newUser` + - Describe hierarchy must mirror the source file path + +7. **Validate Error Messages** + - `expect(bool).toBe(true)` is never acceptable — a failing test must tell you what went wrong without reading the source + - Split complex boolean assertions into individual named assertions + +8. **Check Architecture Consistency** + - When multiple classes serve similar purposes, ensure they have consistent APIs and clear differentiation in their type signatures + - **When extending functionality, create new types rather than modifying existing ones to avoid breaking changes** + - **Ensure similar classes follow the same patterns** + - Flag inconsistent naming or patterns between similar classes + +9. **Check for Over-Engineering** + - Flag additions that were not requested: + - New abstractions, helpers, or utilities for a one-time use + - Features, flags, or configurability that no current caller needs + - Comments explaining what the code does when the names already say it + - Error handling for states that cannot be reached + +10. **Check for Orphaned or Non-Sensical Code** + - Flag functions, methods, or code paths that serve no purpose + - Look for placeholder implementations that never get called + - Identify dead code paths or unreachable logic + - **Flag code with comments like "legacy path" or "no-op here" that should be removed** + - **When removing features, remove all related types, imports, and exports** - don't leave partially implemented type systems + - Verify that all exported functions are actually used somewhere in the codebase + +11. **Validate Import Organization** + - Imports must follow PEP 8-style grouping with a blank line between each group: + 1. Node.js built-ins (`path`, `fs`, etc.) + 2. External packages from `node_modules` + 3. Internal imports from `@/` + - Within each group, alphabetical ordering is required. One import statement per module + +12. **Check Naming Conventions** + - No abbreviations — `workflow` not `wf`, `migration` not `mig` + - Fully qualified names at public boundaries — when data crosses a boundary (API response, email template, event payload), prefix with the parent model name to disambiguate + - SQL — fully spell out table and column names; no abbreviated aliases + - Function names — describe both trigger and behavior + +13. **Verify Expanded Style** + - Avoid terse functional chains. Each logical step must be on its own line or extracted to a named variable + - Extract and rename before constructing objects — never inline a property rename + - No chained transformations — break chains at each step + - Named constants — hoist every magic number or string to a named `const` at the top of the function or file + +14. **Check Service Pattern** + - Services encapsulate business logic and are invoked exclusively via their static `perform()` method + - Never instantiate a service directly outside of its own `static perform()` implementation + - Red flags: `new SomeService(...)` at a call site, business logic in a controller that belongs in a service, a service calling a query directly instead of delegating to another service + +## Output Format + +For each issue: + +``` +[RULE N] +File: : +> +Fix: +``` + +After listing all issues, conclude with one of: +- **APPROVED** — no blocking issues found. +- **CHANGES REQUESTED** — N blocking issues listed above. + +## Related Workflows + +- [`./jira-issue-management.md`](./jira-issue-management.md) - Creating well-structured Jira issues +- [`./pull-request-management.md`](./pull-request-management.md) - Create and update pull requests +- [`./testing-instructions.md`](./testing-instructions.md) - Generate comprehensive testing instructions for pull requests diff --git a/agents/workflows/jira-issue-creation.md b/agents/workflows/jira-issue-creation.md deleted file mode 100644 index 134d08f7..00000000 --- a/agents/workflows/jira-issue-creation.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -description: Workflow for creating well-structured Jira issues in the Traditional Knowledge (TK) project -auto_execution_mode: 1 ---- - -# Jira Issue Creation Workflow - -## Intent - -**WHY this workflow exists:** Creating effective Jira issues requires consistent structure, clear problem descriptions, and actionable requirements. Poorly written issues lead to confusion, scope creep, and implementation delays. - -**WHAT this workflow produces:** Well-structured Jira issues that include: -- Clear problem descriptions or feature requests -- Specific reproduction steps or requirements -- Proper issue type, priority, and component assignment -- Acceptance criteria and testing requirements - -**Decision Rules:** -- **Use Issue Types:** Always select appropriate issue type (Bug, Story, Task, Epic) -- **Stories:** Use user story format "As a [role] I can [action] so that [benefit]" -- **Bugs:** Include reproduction steps and expected behavior -- **Acceptance Criteria:** Always include clear, testable acceptance criteria -- **Components:** Assign to appropriate component (Backend, Frontend, API, etc.) - -## Jira Project Details - -- **Project URL:** https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 -- **Project Key:** TK -- **Board:** Traditional Knowledge Board - ---- - -## Step 1: Choose Issue Type - -| Issue Type | When to Use | Example | -|------------|-------------|---------| -| **Bug** | Defects, errors, broken functionality | Search not returning results | -| **Story** | User-facing features with business value | User can export knowledge entries | -| **Task** | Technical work without direct user value | Update database schema | -| **Epic** | Large features that span multiple stories | Implement cultural protocols system | - ---- - -## Step 2: Fill Out Issue Fields - -### Summary (Title) -- Use clear, concise titles -- For Stories: "As a [role] I can [action]" -- For Bugs: "Area: Brief description of problem" -- For Tasks: "Verb + Noun" format - -### Description Template - -**For Bugs:** -``` -h2. Problem Description -[Clear description of what's wrong] - -h2. Steps to Reproduce -# [Step 1] -# [Step 2] -# [Step 3] - -h2. Expected Behavior -[What should happen] - -h2. Actual Behavior -[What actually happens] - -h2. Environment -* Browser: [e.g., Chrome 120.0] -* OS: [e.g., macOS 14.0] -* User: [Test account if applicable] - -h2. Additional Information -[Error logs, screenshots, related issues] -``` - -**For Stories:** -``` -h2. User Story -As a [user role] I can [perform action] so that [benefit/value] - -h2. Business Value -[Why this matters to users/stakeholders] - -h2. Acceptance Criteria -# Given [context] when [action] then [outcome] -# Given [context] when [action] then [outcome] -# Given [context] when [action] then [outcome] - -h2. Technical Notes -[Implementation considerations, constraints] - -h2. Dependencies -[Prerequisites, blocking issues] - -h2. Definition of Done -- [ ] Code reviewed and approved -- [ ] Tests written and passing -- [ ] Documentation updated -- [ ] Deployed to staging -- [ ] User acceptance testing complete -``` - -**For Tasks:** -``` -h2. Objective -[What needs to be accomplished] - -h2. Requirements -# [Requirement 1] -# [Requirement 2] -# [Requirement 3] - -h2. Technical Approach -[How to implement] - -h2. Definition of Done -- [ ] Task completed -- [ ] Code reviewed -- [ ] Tests pass -``` - ---- - -## Step 3: Set Priority and Components - -### Priority Levels -- **Highest** - Production down, critical security issue -- **High** - Major feature blocked, significant impact -- **Medium** - Important but not blocking -- **Low** - Nice to have, can be deferred - -### Components -- **Backend** - API, database, server-side logic -- **Frontend** - UI components, user interface -- **API** - External integrations, data exchange -- **Infrastructure** - Deployment, CI/CD, monitoring -- **Documentation** - User guides, technical docs - ---- - -## Step 4: Add Labels and Links - -### Common Labels -- `cultural-protocols` - Issues related to Indigenous cultural protocols -- `indigenous-language` - Language support and translation -- `access-control` - Permissions and access management -- `data-migration` - Data import/export/migration tasks -- `performance` - Optimization and performance issues -- `security` - Security-related issues -- `ui/ux` - User interface and experience - -### Linking Issues -- **Blocks/Blocked By** - Dependencies between issues -- **Relates To** - Related but not dependent issues -- **Clones** - Duplicate issues in different contexts - ---- - -## Complete Examples - -### Bug Example - -**Issue Type:** Bug -**Priority:** High -**Component:** Frontend -**Labels:** `ui/ux`, `search` - -``` -h2. Problem Description -Users are unable to search for traditional knowledge entries using Indigenous language terms. The search returns no results even when entries exist. - -h2. Steps to Reproduce -# Navigate to Knowledge Base -# Enter Indigenous language search term -# Click search -# Observe empty results - -h2. Expected Behavior -Search should return knowledge entries matching the Indigenous language terms. - -h2. Actual Behavior -Search returns no results for Indigenous language terms. - -h2. Environment -* Browser: Chrome 120.0 -* OS: Windows 11 -* User: test.knowledge.keeper@yg.gov.yk.ca - -h2. Additional Information -Error in console: "Search index does not include language metadata" -``` - -### Story Example - -**Issue Type:** Story -**Priority:** Medium -**Component:** Frontend, Backend -**Labels:** `cultural-protocols`, `access-control` - -``` -h2. User Story -As a Knowledge Keeper I can set cultural protocols on my entries so that sensitive information is only accessible to authorized community members. - -h2. Business Value -Protects culturally sensitive information while enabling appropriate sharing within the community. Meets legal and ethical obligations for Indigenous knowledge protection. - -h2. Acceptance Criteria -# Given I am a Knowledge Keeper when I create a new entry then I can set cultural protocol restrictions -# Given I set cultural protocols when unauthorized users search then they cannot access restricted content -# Given I am an authorized community member when I search restricted content then I can view it with proper attribution -# Given cultural protocols are set when the entry is exported then protocols are preserved and respected - -h2. Technical Notes -- Need to implement role-based access control -- Cultural protocols must be inherited from community settings -- Audit log required for all access to restricted content - -h2. Dependencies -- TK-123: Implement community management system -- TK-124: Create role-based permission system - -h2. Definition of Done -- [ ] Code reviewed and approved -- [ ] Tests written and passing -- [ ] Documentation updated -- [ ] Deployed to staging -- [ ] User acceptance testing complete with Knowledge Keepers -``` - -### Epic Example - -**Issue Type:** Epic -**Priority:** High -**Component:** Multiple -**Labels:** `cultural-protocols`, `indigenous-language` - -``` -h2. Epic Summary -Implement comprehensive cultural protocols and Indigenous language support throughout the Traditional Knowledge system. - -h2. Business Goal -Ensure the system respects and protects Indigenous cultural protocols while supporting knowledge preservation and sharing in Indigenous languages. - -h2. Stories in this Epic -- TK-201: Cultural protocol management for knowledge entries -- TK-202: Indigenous language search capabilities -- TK-203: Community-based access control -- TK-204: Cultural protocol audit logging -- TK-205: Indigenous language UI localization - -h2. Definition of Done -- All stories completed and accepted -- End-to-end testing with Knowledge Keepers -- Documentation and training materials created -- Cultural approval received from Indigenous partners -``` - ---- - -## Step 5: Create the Issue - -### Option 1: Create via Jira Web UI -1. Go to https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 -2. Click "Create" in top navigation -3. Select Issue Type (Bug, Story, Task, Epic) -4. Fill in fields following templates above -5. Set Priority and Components -6. Add Labels -7. Click "Create" - -### Option 2: Create via Jira CLI (if configured) - -```bash -# Create a new story -jira issue create -p TK -t Story \ - --summary "As a Knowledge Keeper I can export entries to PDF" \ - --description "User Story: As a Knowledge Keeper I can export entries to PDF so that I can share knowledge with community members who need offline access." - -# Create a new bug -jira issue create -p TK -t Bug \ - --summary "Search: Indigenous language terms not returning results" \ - --description "Problem: Search functionality doesn't index Indigenous language metadata" -``` - ---- - -## Quality Checklist - -**Before Creating:** -- [ ] Issue type selected correctly -- [ ] Title is clear and follows conventions -- [ ] Description includes all required sections -- [ ] Acceptance criteria are testable (for stories) -- [ ] Reproduction steps are clear (for bugs) -- [ ] Priority set appropriately -- [ ] Components assigned -- [ ] Relevant labels added - -**After Creating:** -- [ ] Issue appears in correct sprint/backlog -- [ ] Links to related issues added -- [ ] Assignee notified (if assigned) -- [ ] Watchers added as needed - ---- - -## Best Practices - -1. **One Issue, One Problem** - Don't combine multiple unrelated issues -2. **Clear Acceptance Criteria** - Make them specific and measurable -3. **User-Centric Stories** - Focus on user value, not technical implementation -4. **Proper Context** - Include enough information for anyone to understand -5. **Link Dependencies** - Always link related or blocking issues -6. **Regular Updates** - Keep issue status and comments current - ---- - -**Workflow Version:** 1.0 -**Last Updated:** 2026-01-22 -**Project:** Traditional Knowledge (TK) - https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 diff --git a/agents/workflows/jira-issue-management.md b/agents/workflows/jira-issue-management.md new file mode 100644 index 00000000..8f17f214 --- /dev/null +++ b/agents/workflows/jira-issue-management.md @@ -0,0 +1,403 @@ +--- +description: Create, enhance, and manage well-structured Jira issues using project conventions +auto_execution_mode: 1 +--- + +# Jira Issue Management Workflow + +## Intent + +**WHY this workflow exists:** Creating and managing effective Jira issues requires consistent structure, clear problem descriptions, and actionable requirements. Poorly written issues lead to confusion, scope creep, and implementation delays, while proper enhancement patterns ensure issues remain valuable throughout their lifecycle. + +**WHAT this workflow produces:** Well-structured Jira issues that include: + +- Clear problem descriptions or feature requests +- Specific reproduction steps or requirements +- Proper issue labeling and assignment +- Screenshots/mockups when relevant +- Enhanced descriptions with visual evidence and external source links +- Proper dependency relationships between issues + +**Decision Rules:** + +- **Use Jira Web UI:** Create issues directly in TK project at https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 +- **Title Case Formatting:** Use title case for Jira ticket titles - capitalize major words, keep minor words (articles, prepositions, conjunctions) lowercase +- **Bug Reports:** Use "Bug" issue type for defects and problems +- **Feature Requests:** Use "User Story" issue type for new functionality (validate with getJiraProjectIssueTypesMetadata first) +- **Improvements/Refactoring:** Use "Task" issue type +- **Large Features:** Use "Epic" issue type with multiple child issues +- **Labels:** Always include appropriate labels for type, priority, and component +- **User Reports:** Integrate into main description when primary context; add as separate comment for supplemental information +- **Visual Evidence:** Embed screenshots directly in descriptions when available for immediate context +- **External Sources:** Create remote links with proper global IDs (MD5 hash pattern) for external references +- **Dependencies:** Use proper issue linking ("causes"/"is caused by") to establish technical relationships +- **MCP Tool Validation:** Always validate available options before using MCP tools (e.g., check issue types with getJiraProjectIssueTypesMetadata before creating) +- **Complete workflow sequence:** This is step 1 of 4 in the complete PR creation process. Always use before code-review, pull-request-management, and testing-instructions workflows to ensure proper issue structure and requirements gathering. + +## Reference Files + +- TK Jira Board: https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 +- Jira Issue Types: Bug, User Story, Task, Epic (validate with getJiraProjectIssueTypesMetadata) +- TK Project Labels and Components + +--- + +## Step 1: Choose Issue Type + +**For Bugs/Defects:** Use "Bug" issue type +**For New Features:** Use "User Story" issue type (validate with getJiraProjectIssueTypesMetadata first) +**For Improvements/Refactoring:** Use "Task" issue type +**For Large Features:** Use "Epic" issue type with multiple child issues + +--- + +## Step 2: Fill Out Issue Fields + +**Bug Issue Fields:** +- **Summary:** Clear, concise description of the problem +- **Description:** Detailed problem explanation with reproduction steps +- **Priority:** Set appropriate priority level +- **Labels:** Add relevant labels (bug, easy-win, etc.) +- **Components:** Assign to relevant component if applicable + +**Story/Task Issue Fields:** +- **Summary:** Clear description of feature or improvement +- **Description:** Problem context and solution requirements +- **Priority:** Set appropriate priority level +- **Labels:** Add relevant labels (enhancement, refactor, etc.) +- **Components:** Assign to relevant component if applicable + +--- + +## Step 3: Write Effective Context + +**Enhanced Description Patterns:** + +- **Visual Evidence Integration**: Embed screenshots directly in descriptions when available - they provide immediate context that text alone cannot convey +- **Precise Reproduction Steps**: Include specific trigger conditions rather than generic instructions +- **Impact Conciseness**: Use single, powerful statements that capture the essence of the business impact +- **External Source Linking**: Create remote links with proper global IDs (using MD5 hash pattern) for external references +- **Dependency Relationships**: Use proper issue linking ("causes"/"is caused by") to establish clear technical relationships between tickets +- **Description Integration**: Move user reports into the main description rather than separate comments when they're the primary context +- **Clean Structure**: Remove redundant comments when the information is properly integrated into the main description +- **Factual Accuracy**: Differentiate clearly between definite facts (user quotes, confirmed behaviors) and probable hypotheses (potential causes). Never guess or invent technical details +- **Actionable Investigation**: Provide specific file names, service names, and concrete technical steps rather than general possibilities +- **Focused Root Causes**: Prioritize the most probable causes (2-3 focused hypotheses) rather than exhaustive possibilities, but always label them as "possible" or "potential" +- **Implementation Hints**: Include specific technical solutions when known + +**For Bug Reports:** + +- Include exact error messages +- Provide specific URLs or page names +- Include browser console errors if applicable +- Mention recent changes that might be related +- Add screenshots directly in description when available + +**For Feature Requests:** + +- Use simple structure: Context → User Report → Proposed Solution(s) +- Keep descriptions concise and focused +- Use numbered options for different approaches +- Reference related tickets where applicable + +--- + +## Step 4: Add Labels and Assignment + +**TK Project Labels:** +- `bug` - Something isn't working +- `enhancement` - Adds or modifies features to improve functionality or user experience +- `refactor` - Improves code's internal structure without changing its behavior +- `documentation` - Improvements or additions to documentation +- `easy-win` - Quick fixes that provide significant user value +- `cultural-protocols` - Issues related to Indigenous cultural protocols +- `indigenous-language` - Language support and translation +- `access-control` - Permissions and access management +- `data-migration` - Data import/export/migration tasks + +**Priority Levels:** +- **Highest** - Critical issues blocking production +- **High** - Important issues affecting core functionality +- **Medium** - Standard priority for most issues +- **Low** - Minor issues or nice-to-have improvements +- **Lowest** - Cosmetic issues or very low impact + +**Assignment:** +- Assign to appropriate team member based on expertise +- If unsure, leave unassigned for team lead to assign + +--- + +## Complete Examples + +### Bug Report Example + +**Title:** `Fix: Knowledge Entry Search Not Returning Indigenous Language Results` + +**Labels:** `bug`, `indigenous-language` + +**Body:** + +``` +**Describe the bug** +Users are unable to search for traditional knowledge entries using Indigenous language terms. The search returns no results even when entries exist with matching terms. + +**To Reproduce** +Steps to reproduce the behavior: +1. Navigate to Knowledge Base +2. Enter an Indigenous language search term +3. Click search +4. Observe empty results + +**Expected behavior** +Search should return knowledge entries matching the Indigenous language terms. + +**Desktop (please complete your following information):** +- OS: Windows 11 +- Browser: Chrome 120.0.6099.129 +- Version: Latest + +**Additional context** +Error in console: "Search index does not include language metadata" +``` + +### Feature Request Example + +**Title:** `Enhancement: Add Cultural Protocol Management for Knowledge Entries` + +**Labels:** `enhancement`, `cultural-protocols` + +**Body:** + +``` +# Context + +Knowledge Keepers need the ability to set cultural protocols on entries so that sensitive information is only accessible to authorized community members. Currently there is no way to restrict access based on cultural sensitivity. + +## User Report + +> We need to be able to mark certain knowledge as restricted so only authorized community members can view it. + +## Proposed Solutions + +### Option 1 + +Add cultural protocol settings directly on each knowledge entry with role-based access control. + +### Option 2 + +Implement community-level protocol templates that can be applied to entries. + +### Option 3 + +Create a tiered access system with community approval workflows. +``` + +### Refactoring Example + +**Title:** `Refactor: Standardize Information Sharing Agreement Service Pattern` + +**Labels:** `refactor` + +**Body:** + +``` +# Context + +Current ISA services have inconsistent patterns between create, update, and sign flows, leading to potential data integrity issues and maintenance overhead. + +## User Report + +> We're seeing inconsistencies in how agreements are processed depending on the action taken. + +## Proposed Solutions + +### Option 1 + +Standardize all ISA services to use the BaseService pattern with consistent validation and group management. + +### Option 2 + +Keep current service patterns with manual oversight. +``` + +--- + +## Step 5: Create the Issue + +### Option 1: Create via Jira Web UI + +1. Go to https://yg-hpw.atlassian.net/jira/software/projects/TK/boards/27 +2. Click **Create** in the top navigation +3. Select appropriate issue type (Bug, Story, Task, Epic) +4. Fill in Summary and Description fields following the examples above +5. Set Priority and add relevant Labels +6. Assign to appropriate team member if known +7. Click **Create** + +### Option 2: Create via Jira REST API + +**For Bug Reports:** +```bash +curl -X POST "https://yg-hpw.atlassian.net/rest/api/3/issue" \ + -H "Authorization: Basic $(echo -n 'email:api_token' | base64)" \ + -H "Content-Type: application/json" \ + -d '{ + "fields": { + "project": { "key": "TK" }, + "issuetype": { "name": "Bug" }, + "summary": "Bug: [Brief Description]", + "description": { + "type": "doc", + "version": 1, + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": "**Describe the bug**\n[Clear description]\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. [Step 1]\n2. [Step 2]\n3. [Step 3]\n\n**Expected behavior**\n[What should happen]\n\n**Desktop (please complete your following information):**\n- OS: [e.g. macOS 14.0]\n- Browser: [e.g. Chrome 120.0.6099.129]\n- Version: Latest\n\n**Additional context**\n[Any relevant extra information]" + }] + }] + }, + "priority": { "name": "Medium" }, + "labels": ["bug", "easy-win"] + } + }' +``` + +**For Feature Requests:** +```bash +curl -X POST "https://yg-hpw.atlassian.net/rest/api/3/issue" \ + -H "Authorization: Basic $(echo -n 'email:api_token' | base64)" \ + -H "Content-Type: application/json" \ + -d '{ + "fields": { + "project": { "key": "TK" }, + "issuetype": { "name": "Story" }, + "summary": "Feature: [Brief Description]", + "description": { + "type": "doc", + "version": 1, + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": "Relates to:\n- [Related issues or documentation]\n\n# Context\n\n**Is your feature request related to a problem? Please describe.**\n[Problem description]\n\n**Describe the solution you would like**\n[Clear solution description]\n\n**Describe alternatives you have considered**\n[Alternative approaches]\n\n**Additional context**\n[Extra details, examples, or context]" + }] + }] + }, + "priority": { "name": "Medium" }, + "labels": ["enhancement"] + } + }' +``` + +--- + +## Enhancing Existing Issues + +### When to Enhance + +- Adding visual evidence after initial creation +- Integrating user reports from external sources +- Adding dependency relationships between issues +- Updating descriptions with investigation findings + +### Enhancement Patterns + +**Adding Remote Links:** +```bash +# Generate MD5 hash global ID +GLOBAL_ID=$(echo -n "https://external-system.com/conversation/unique-id" | md5sum | cut -c1-8) + +# Create remote link +curl -X POST "https://yg-hpw.atlassian.net/rest/api/3/issue/{issueKey}/remotelink" \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic $(echo -n '${JIRA_EMAIL}:${JIRA_API_TOKEN}' | base64)" \ + -d "{ + \"globalId\": \"${GLOBAL_ID}\", + \"object\": { + \"url\": \"https://external-system.com/conversation/unique-id\", + \"title\": \"User Report - UserName \" + } + }" +``` + +**Issue Linking Patterns:** +- Use "causes"/"is caused by" for technical dependencies +- Use "relates to" for related but independent issues +- Link to parent/child issues for hierarchical relationships + +--- + +## Quality Checklist + +**For Bug Reports:** + +- [ ] Bug description is clear and concise +- [ ] Reproduction steps are numbered and specific +- [ ] Expected vs actual behavior is clearly stated +- [ ] Screenshots included when applicable +- [ ] Browser/OS version information provided +- [ ] Error messages included verbatim +- [ ] Recent changes mentioned if relevant + +**For Feature Requests:** + +- [ ] Uses simple structure: Context → User Report → Proposed Solutions +- [ ] User report included as direct quote +- [ ] Problem context is clearly explained +- [ ] Multiple solution options provided when applicable +- [ ] Related tickets referenced where applicable + +**General:** + +- [ ] Appropriate labels assigned (type, priority, component) +- [ ] Related issues or documentation linked +- [ ] Title is descriptive and follows project conventions +- [ ] Title uses proper title case formatting +- [ ] Issue is assigned to appropriate team member if applicable + +--- + +## TK-Specific Considerations + +### User Reports Integration + +For issues reported by users: + +1. **Primary Context**: Integrate user reports into main description when they're the primary context for the issue +2. **Supplemental Information**: Add as separate comment with web link when providing additional context +3. **Remote Links**: Create remote links with proper global IDs for external references +4. **Format**: Use clear "User Report" section with reporter's email and original description +5. **Visual Evidence**: Include screenshots directly in description when available + +### Common TK Context Patterns + +**Knowledge Entry Issues:** +- Include specific entry types and categories +- Reference cultural protocol levels +- Include language metadata when relevant + +**Information Sharing Agreement Issues:** +- Include agreement status and parties involved +- Reference group creation and access control +- Include signing workflow context + +**Access Control Issues:** +- Include user roles and permissions +- Reference group membership +- Include cultural protocol restrictions + +--- + +## Related Workflows + +- [`./code-review.md`](./code-review.md) - Code review quality control +- [`./pull-request-management.md`](./pull-request-management.md) - Create and update pull requests +- [`./testing-instructions.md`](./testing-instructions.md) - Generate comprehensive testing instructions + +--- + +**Last Updated:** 2026-03-12 + +_Update this workflow when you discover better patterns or Traditional Knowledge project conventions evolve._ diff --git a/agents/workflows/pull-request-management.md b/agents/workflows/pull-request-management.md index 8545627a..1205dd5d 100644 --- a/agents/workflows/pull-request-management.md +++ b/agents/workflows/pull-request-management.md @@ -21,6 +21,8 @@ auto_execution_mode: 1 - **Implementation section:** Focus on purpose and intent, not specific files. A reviewer can see file changes in the diff - the Implementation section explains the reasoning behind those changes. - **Screenshots:** Check the diff for `web/src/components/` or `web/src/pages/` changes. If present, write "TODO" and let user add screenshots. Only write "N/A - backend changes only" if there are truly no frontend changes. - **Draft mode:** Always create PRs as drafts first +- **QA Testing:** Write testing instructions for someone with zero project knowledge, focusing on user interactions rather than technical implementation. Follow the `testing-instructions` workflow for comprehensive guidance on creating detailed, accurate testing instructions with exact UI element names and proper test case structure. +- **Complete workflow sequence:** This is step 3 of 4 in the complete PR creation process. Always use after jira-issue-management and code-review workflows, then follow with testing-instructions workflow for comprehensive test coverage. This workflow covers the process of creating and editing well-structured pull requests that follow the established patterns in the Traditional Knowledge project. @@ -478,6 +480,14 @@ The current system only supports screen viewing and printing, making it difficul --- -**Last Updated:** 2026-01-23 +## Related Workflows + +- [`./jira-issue-management.md`](./jira-issue-management.md) - Creating well-structured Jira issues +- [`./testing-instructions.md`](./testing-instructions.md) - Generate comprehensive testing instructions +- [`./code-review.md`](./code-review.md) - Code review quality control + +--- + +**Last Updated:** 2026-03-12 *Update this workflow when you discover better patterns or Traditional Knowledge project conventions evolve.* diff --git a/agents/workflows/testing-instructions.md b/agents/workflows/testing-instructions.md new file mode 100644 index 00000000..9e238846 --- /dev/null +++ b/agents/workflows/testing-instructions.md @@ -0,0 +1,226 @@ +--- +description: Generate comprehensive testing instructions for pull requests +--- + +# Testing Instructions Workflow + +This workflow guides you through creating comprehensive, accurate testing instructions for a pull request. + +## Intent + +**WHY this workflow exists:** Pull requests need clear, actionable testing instructions that developers can follow to validate changes. Without proper testing instructions, PR validation becomes inconsistent and error-prone. + +**WHAT this workflow produces:** +- Comprehensive testing instructions with exact UI element names +- Sequential test cases covering happy paths, edge cases, and error conditions +- Proper formatting following Traditional Knowledge project standards +- Navigation paths and verification steps for each test scenario + +**Decision Rules:** +- **Always verify UI element names**: Never guess button names, tab names, or labels - search the Vue code +- **Use exact formatting**: Follow the established bold formatting and sequential numbering patterns +- **Cover all scenarios**: Include happy paths, edge cases, error conditions, and cleanup flows +- **Be specific**: Include exact navigation paths, field labels, and expected outcomes +- **Complete workflow sequence:** This is step 4 of 4 in the complete PR creation process. Always use after jira-issue-management, code-review, and pull-request-management workflows to provide comprehensive test coverage for the created PR. + +--- + +## Process + +### Step 1: Understand the PR changes + +Read the PR description to understand: +- What feature or bug is being addressed +- What specific functionality changed +- What edge cases need testing +- Any concerns/questions raised + +Identify the main test scenarios that need coverage. + +### Step 2: Find actual UI element names from Vue code + +**CRITICAL**: Always verify exact button names, tab names, and UI element labels from the actual Vue components. Never guess or assume names. + +#### Finding button text and labels: + +```bash +# Find button text in Vue components +grep -r "v-btn" web/src --include="*.vue" | grep -i "keyword" + +# Find specific button labels +grep -r ">Create" web/src --include="*.vue" +grep -r ">Add" web/src --include="*.vue" +grep -r ">Save" web/src --include="*.vue" + +# Find tab names +grep -r "v-tab" web/src --include="*.vue" + +# Find form field labels +grep -r "label=" web/src --include="*.vue" | grep -i "keyword" +``` + +#### Finding navigation structure: + +```bash +# Find router paths and page names +grep -r "router-link" web/src --include="*.vue" | grep -i "keyword" +grep -r "to=" web/src --include="*.vue" | grep -i "keyword" + +# Find menu items +grep -r "v-list-item" web/src --include="*.vue" +grep -r "v-navigation" web/src --include="*.vue" +``` + +#### Example searches for common UI patterns: + +```bash +# Agreement-related buttons +grep -r "New Agreement" web/src --include="*.vue" +grep -r "Create" web/src --include="*.vue" + +# Tab components +grep -r 'v-tab.*Details' web/src --include="*.vue" +grep -r 'v-tab.*History' web/src --include="*.vue" + +# Form buttons +grep -r "Submit" web/src --include="*.vue" +grep -r "Cancel" web/src --include="*.vue" +``` + +### Step 3: Verify navigation paths + +Check the actual navigation structure: + +```bash +# Find page components and their routes +cat web/src/router/index.ts + +# Find sidebar navigation items +find web/src/components -name "*Nav*" -o -name "*Sidebar*" -o -name "*Menu*" +``` + +Read relevant navigation components to understand: +- Exact menu item text +- Navigation hierarchy +- Page locations + +### Step 4: Structure test cases + +Break testing into logical test cases: +- **Test Case 1**: Main happy path scenario +- **Test Case 2**: Edge cases and variations +- **Test Case 3**: Error conditions or negative tests +- **Test Case 4**: Additional scenarios specific to the feature + +Each test case should: +- Have a clear descriptive heading +- Test one specific aspect of the functionality +- Include verification steps with expected outcomes + +### Step 5: Write testing instructions following the standard format + +Use this exact structure: + +```markdown +# Testing Instructions + +1. Run the test suite via `dev test`. +2. Boot the app via `dev up`. +3. Log in to the app at http://localhost:3000. + +## Test Case 1: [Descriptive scenario name] + +4. From the main app Dashboard, [first action] +5. Click **[Exact Button Name]** button +6. Fill in [form/dialog details]: + - **[Field Label]**: [Instructions or example value] + - **[Another Field]**: [Instructions] +7. Click **[Submit Button Name]** to submit +8. Verify [expected outcome] +9. Navigate to **[Page/Tab Name]** via [navigation path] +10. Verify [specific validation] + +## Test Case 2: [Another scenario] + +11. [Continue numbered sequence] +... +``` + +### Step 6: Follow formatting guidelines + +**Required formatting rules:** + +1. **Bold all UI elements**: `**Create Entry**`, `**Details** tab`, `**Add User** button` +2. **Use exact button text**: Search the code to find the actual button text, don't guess +3. **Sequential numbering**: Number steps continuously across all test cases (don't restart at 1) +4. **Navigation arrows**: Use `→` for navigation paths: `**Information Sharing Agreements** → **New Agreement** → **Details** tab` +5. **Specific locations**: "From the main app Dashboard", "in the right hand side panel", "via the left sidebar nav" +6. **Clear verification steps**: "Verify the table displays **Column Name** column", "Verify success message: 'exact text'" +7. **Test case headings**: Use `## Test Case N: Description` format +8. **Inline code for values**: Use backticks for URLs, exact error messages, field values + +### Step 7: Include all test scenarios + +Ensure coverage of: +- Main happy path functionality +- Edge cases (e.g., empty states, boundary conditions) +- Error conditions (e.g., validation errors, permission errors) +- Data persistence (verify data survives page refresh if relevant) +- Cleanup/deletion flows +- Any concerns mentioned in the PR description + +### Step 8: Review and validate + +Before finalizing: +- [ ] All UI element names match actual code +- [ ] Navigation paths are accurate +- [ ] All test cases have clear expected outcomes +- [ ] Sequential numbering is correct +- [ ] Bold formatting is applied to all UI elements +- [ ] Each test case tests a distinct scenario +- [ ] Edge cases from PR description are covered + +## Example Output + +```markdown +# Testing Instructions + +1. Run the test suite via `dev test`. +2. Boot the app via `dev up`. +3. Log in to the app at http://localhost:3000. + +## Test Case 1: User can create a new information sharing agreement + +4. Click **Information Sharing Agreements** in the left sidebar nav +5. Click **New Agreement** button +6. Fill in the agreement details: + - **Title**: Enter "Test Agreement" + - **Yukon First Nation or Transboundary Contact Name**: Select a contact +7. Click **Save** to submit +8. Verify the agreement appears in the list with status "Draft" +9. Click the agreement to open it +10. Verify all fields are populated correctly + +## Test Case 2: User can sign an agreement and groups are created + +11. From the agreement detail page, click the **Sign** button +12. Fill in the signing details +13. Click **Confirm** +14. Verify the agreement status changes to "Signed" +15. Navigate to the **Groups** section +16. Verify sharing and receiving groups were created automatically +``` + +## Related Workflows + +- [`./jira-issue-management.md`](./jira-issue-management.md) - Creating well-structured Jira issues +- [`./pull-request-management.md`](./pull-request-management.md) - Create and update pull requests +- [`./code-review.md`](./code-review.md) - Code review quality control + +## Tips + +- When in doubt about UI element names, always check the code first +- Pay attention to Vuetify component patterns (v-btn, v-tab, v-dialog, etc.) +- Check both the component files and any related store/service files +- Test instructions should be detailed enough that someone unfamiliar with the feature can follow them +- Include both success paths and failure paths when relevant diff --git a/api/package-lock.json b/api/package-lock.json index 23337a96..8f5848d0 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@azure/storage-blob": "^12.28.0", "@paralleldrive/cuid2": "^2.2.2", - "@sequelize/core": "^7.0.0-alpha.40", - "@sequelize/mssql": "^7.0.0-alpha.40", + "@sequelize/core": "^7.0.0-alpha.48", + "@sequelize/mssql": "^7.0.0-alpha.48", "axios": "^1.12.2", "cors": "^2.8.5", "docxtemplater": "^3.67.6", @@ -32,6 +32,7 @@ "node-schedule": "^2.1.1", "nodemailer": "^6.10.1", "pizzip": "^3.2.0", + "qs": "^6.14.0", "redis": "^4.7.1", "uuid": "^11.1.0", "winston": "^3.18.3", @@ -8633,9 +8634,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/api/package.json b/api/package.json index a3a98d52..f974b76e 100644 --- a/api/package.json +++ b/api/package.json @@ -21,8 +21,8 @@ "dependencies": { "@azure/storage-blob": "^12.28.0", "@paralleldrive/cuid2": "^2.2.2", - "@sequelize/core": "^7.0.0-alpha.40", - "@sequelize/mssql": "^7.0.0-alpha.40", + "@sequelize/core": "^7.0.0-alpha.48", + "@sequelize/mssql": "^7.0.0-alpha.48", "axios": "^1.12.2", "cors": "^2.8.5", "docxtemplater": "^3.67.6", @@ -42,6 +42,7 @@ "node-schedule": "^2.1.1", "nodemailer": "^6.10.1", "pizzip": "^3.2.0", + "qs": "^6.14.0", "redis": "^4.7.1", "uuid": "^11.1.0", "winston": "^3.18.3", diff --git a/api/src/controllers/archive-items-controller.ts b/api/src/controllers/archive-items-controller.ts index d408e884..5a311727 100644 --- a/api/src/controllers/archive-items-controller.ts +++ b/api/src/controllers/archive-items-controller.ts @@ -1,11 +1,12 @@ import { isNil } from "lodash" +import logger from "@/utils/logger" + import { ArchiveItem, ArchiveItemAudit } from "@/models" import { ArchiveItemsPolicy } from "@/policies" -import BaseController from "@/controllers/base-controller" -import { CreateService } from "@/services/archive-items" +import { CreateService, DestroyService } from "@/services/archive-items" import { IndexSerializer, ShowSerializer } from "@/serializers/archive-items" -import logger from "@/utils/logger" +import BaseController from "@/controllers/base-controller" export class ArchiveItemsController extends BaseController { async index() { @@ -35,6 +36,44 @@ export class ArchiveItemsController extends BaseController { } } + async show() { + try { + const archiveItem = await this.loadArchiveItem() + if (isNil(archiveItem)) { + return this.response.status(404).json({ + message: "Archive item not found", + }) + } + + const policy = this.buildPolicy(archiveItem) + if (!policy.show()) { + return this.response.status(403).json({ + message: "You are not authorized to view this item", + }) + } + + // TODO: move to /services/archive-items/show-service.ts + await ArchiveItemAudit.create({ + archiveItemId: archiveItem.id, + action: "Viewed Metadata", + userId: this.currentUser.id, + description: `${this.currentUser.displayName} viewed metadata`, + }) + + const serializedArchiveItem = ShowSerializer.perform(archiveItem) + + return this.response.json({ + archiveItem: serializedArchiveItem, + policy, + }) + } catch (error) { + logger.error(`Error fetching item: ${error}`, { error }) + return this.response.status(400).json({ + message: `Error fetching item: ${error}`, + }) + } + } + async create() { try { const policy = this.buildPolicy() @@ -75,7 +114,7 @@ export class ArchiveItemsController extends BaseController { } } - async show() { + async destroy() { try { const archiveItem = await this.loadArchiveItem() if (isNil(archiveItem)) { @@ -85,42 +124,30 @@ export class ArchiveItemsController extends BaseController { } const policy = this.buildPolicy(archiveItem) - if (!policy.show()) { + if (!policy.destroy()) { return this.response.status(403).json({ - message: "You are not authorized to view this item", + message: "You are not authorized to delete this item", }) } - // TODO: move to /services/archive-items/show-service.ts - await ArchiveItemAudit.create({ - archiveItemId: archiveItem.id, - action: "Viewed Metadata", - userId: this.currentUser.id, - description: `${this.currentUser.displayName} viewed metadata`, - }) - - const serializedArchiveItem = ShowSerializer.perform(archiveItem) - - return this.response.json({ - archiveItem: serializedArchiveItem, - policy, - }) + await DestroyService.perform(archiveItem, this.currentUser) + return this.response.status(204).send() } catch (error) { - logger.error(`Error fetching item: ${error}`, { error }) - return this.response.status(400).json({ - message: `Error fetching item: ${error}`, + logger.error(`Error deleting archive item: ${error}`, { error }) + return this.response.status(422).json({ + message: `Error deleting archive item: ${error}`, }) } } - private async loadArchiveItem() { - const item = await ArchiveItem.findByPk(this.params.id, { + private loadArchiveItem() { + return ArchiveItem.findByPk(this.params.archiveItemId, { include: [ "files", "user", { association: "categories", through: { attributes: [] } }, { - association: "informationSharingAgreementAccessGrants", + association: "accessGrants", through: { // NOTE: suppressing through model attributes as their names are too long attributes: [], @@ -128,9 +155,6 @@ export class ArchiveItemsController extends BaseController { }, ], }) - if (isNil(item)) return null - - return item } private buildPolicy(archiveItem: ArchiveItem = ArchiveItem.build()) { diff --git a/api/src/controllers/base-controller.ts b/api/src/controllers/base-controller.ts index 0e3e9933..c4e34239 100644 --- a/api/src/controllers/base-controller.ts +++ b/api/src/controllers/base-controller.ts @@ -180,7 +180,7 @@ export class BaseController { } get files() { - return this.request.files + return this.request.files || {} } buildWhere( diff --git a/api/src/controllers/information-sharing-agreements-controller.ts b/api/src/controllers/information-sharing-agreements-controller.ts index 08abf2b8..be16b8ae 100644 --- a/api/src/controllers/information-sharing-agreements-controller.ts +++ b/api/src/controllers/information-sharing-agreements-controller.ts @@ -27,7 +27,8 @@ export class InformationSharingAgreementsController extends BaseController { + async create() { + try { + const informationSharingAgreement = await this.loadInformationSharingAgreement() + if (isNil(informationSharingAgreement)) { + return this.response.status(404).json({ + message: "Information sharing agreement not found.", + }) + } + + const informationSharingAgreementPolicy = this.buildInformationSharingAgreementPolicy( + informationSharingAgreement + ) + if (!informationSharingAgreementPolicy.show()) { + return this.response.status(403).json({ + message: "You are not authorized to view this information sharing agreement.", + }) + } + + const policy = this.buildPolicy() + if (!policy.create()) { + return this.response.status(403).json({ + message: + "You are not authorized to create archive items for this information sharing agreement.", + }) + } + + const permittedAttributes = policy.permitAttributesForCreate(this.request.body) + const archiveItem = await InformationSharingAgreements.ArchiveItems.CreateService.perform( + informationSharingAgreement, + permittedAttributes, + this.currentUser + ) + + const serializedArchiveItem = ShowSerializer.perform(archiveItem) + return this.response.status(201).json({ + archiveItem: serializedArchiveItem, + policy, + }) + } catch (error) { + logger.error(`Error creating archive item: ${error}`, { error }) + return this.response.status(422).json({ + message: `Error creating archive item: ${error}`, + }) + } + } + + private async loadInformationSharingAgreement(): Promise { + return InformationSharingAgreement.findByPk(this.params.informationSharingAgreementId) + } + + private buildInformationSharingAgreementPolicy( + informationSharingAgreement: InformationSharingAgreement + ): InformationSharingAgreementPolicy { + return new InformationSharingAgreementPolicy(this.currentUser, informationSharingAgreement) + } + + private buildPolicy(archiveItem: ArchiveItem = ArchiveItem.build()): ArchiveItemsPolicy { + archiveItem.accessGrants ??= [] + return new ArchiveItemsPolicy(this.currentUser, archiveItem) + } +} + +export default ArchiveItemsController diff --git a/api/src/controllers/information-sharing-agreements/index.ts b/api/src/controllers/information-sharing-agreements/index.ts index b50228ac..6aa74031 100644 --- a/api/src/controllers/information-sharing-agreements/index.ts +++ b/api/src/controllers/information-sharing-agreements/index.ts @@ -1,3 +1,4 @@ +export { ArchiveItemsController } from "./archive-items-controller" export { GenerateAcknowledgementController } from "./generate-acknowledgement-controller" export { RevertToDraftController } from "./revert-to-draft-controller" export { SignController } from "./sign-controller" diff --git a/api/src/db/migrations/20260227210312_update-archive-item-information-sharing-agreement-access-grants-view.ts b/api/src/db/migrations/20260227210312_update-archive-item-information-sharing-agreement-access-grants-view.ts new file mode 100644 index 00000000..942d2fa2 --- /dev/null +++ b/api/src/db/migrations/20260227210312_update-archive-item-information-sharing-agreement-access-grants-view.ts @@ -0,0 +1,35 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + await knex.raw(/* sql */ ` + CREATE OR ALTER VIEW archive_item_information_sharing_agreement_access_grants AS + SELECT + information_sharing_agreement_archive_items.archive_item_id, + information_sharing_agreement_archive_items.information_sharing_agreement_id, + information_sharing_agreement_access_grants.id AS access_grant_id, + information_sharing_agreement_access_grants.group_id, + information_sharing_agreement_access_grants.user_id, + information_sharing_agreement_access_grants.access_level + FROM + information_sharing_agreement_archive_items + JOIN information_sharing_agreement_access_grants ON information_sharing_agreement_access_grants.information_sharing_agreement_id = information_sharing_agreement_archive_items.information_sharing_agreement_id + WHERE + information_sharing_agreement_archive_items.deleted_at IS NULL + AND information_sharing_agreement_access_grants.deleted_at IS NULL; + `) +} + +export async function down(knex: Knex): Promise { + await knex.raw(/* sql */ ` + CREATE OR ALTER VIEW archive_item_information_sharing_agreement_access_grants AS + SELECT + information_sharing_agreement_archive_items.archive_item_id, + information_sharing_agreement_access_grants.id AS information_sharing_agreement_access_grant_id + FROM + information_sharing_agreement_archive_items + JOIN information_sharing_agreement_access_grants ON information_sharing_agreement_access_grants.information_sharing_agreement_id = information_sharing_agreement_archive_items.information_sharing_agreement_id + WHERE + information_sharing_agreement_archive_items.deleted_at IS NULL + AND information_sharing_agreement_access_grants.deleted_at IS NULL; + `) +} diff --git a/api/src/db/seeds/development/005.add-categories.ts b/api/src/db/seeds/development/005.add-categories.ts new file mode 100644 index 00000000..a6bea313 --- /dev/null +++ b/api/src/db/seeds/development/005.add-categories.ts @@ -0,0 +1,40 @@ +import { Knex } from "knex" +import { isNil } from "lodash" + +import logger from "@/utils/logger" +import { Category } from "@/models" + +export async function seed(_knex: Knex): Promise { + const categoriesAttributes = [ + { + name: "Cultural Knowledge", + description: + "Traditional practices, ceremonies, language, oral history, and cultural teachings", + }, + { + name: "Land and Environment", + description: + "Traditional territory, land use, environmental knowledge, and natural resource management", + }, + { + name: "Governance and Law", + description: + "Traditional governance, laws, decision-making processes, and community protocols", + }, + ] + + for (const categoryAttributes of categoriesAttributes) { + let category = await Category.findOne({ + where: { + name: categoryAttributes.name, + }, + }) + if (isNil(category)) { + category = await Category.create(categoryAttributes) + logger.debug("Category created:", { category: category.dataValues }) + } else { + await category.update(categoryAttributes) + logger.debug("Category updated:", { category: category.dataValues }) + } + } +} diff --git a/api/src/integrations/blob-storage-integration.ts b/api/src/integrations/blob-storage-integration.ts new file mode 100644 index 00000000..5d3da274 --- /dev/null +++ b/api/src/integrations/blob-storage-integration.ts @@ -0,0 +1,86 @@ +import { BlobServiceClient, BlockBlobClient, ContainerClient } from "@azure/storage-blob" +import { v4 as uuidV4 } from "uuid" +import { isNil } from "lodash" + +import { BLOB_CONNECTION_STRING, BLOB_CONTAINER } from "@/config" + +export class BlobStorageIntegration { + private static _instance: BlobStorageIntegration + private _serviceClient?: BlobServiceClient + private _containerClient?: ContainerClient + + static get instance() { + if (this._instance) return this._instance + + this._instance = new BlobStorageIntegration() + return this._instance + } + + static async uploadFile(localFilePath: string): Promise { + return this.instance.uploadFile(localFilePath) + } + + static async downloadFile(remoteFileIdentifier: string): Promise { + return this.instance.downloadFile(remoteFileIdentifier) + } + + static async deleteFile(remoteFileIdentifier: string): Promise { + return this.instance.deleteFile(remoteFileIdentifier) + } + + /** + * Uploads a file to Azure Blob Storage and returns the remote file name (blob name). + * + * The remote file name acts as a "file handle" or identifier for the uploaded blob. + * While Azure Blob Storage supports full path structures (e.g., "documents/reports/file.pdf"), + * we treat this identifier as an opaque handle - we don't care about its structure + * or whether it represents a path. We generate it once and use it consistently + * for all operations (download, delete) on that blob. + * + * This approach simplifies our application by: + * - Abstracting away Azure's path-based storage structure + * - Treating the remote file name as a unique identifier, not a path + * - Allowing Azure to handle the actual storage organization + * - Maintaining a simple file-handle-like interface for blob operations + * + * @param localFilePath The path to the local file to upload. + * @returns The remote file identifier (blob name) that acts as a file handle for future operations. + */ + async uploadFile(localFilePath: string): Promise { + const remoteFileIdentifier = uuidV4() // a.k.a. blob name or remote file handle + const client = this.getBlockBlobClient(remoteFileIdentifier) + await client.uploadFile(localFilePath) + return remoteFileIdentifier + } + + async downloadFile(remoteFileIdentifier: string): Promise { + const client = this.getBlockBlobClient(remoteFileIdentifier) + return client.downloadToBuffer() + } + + async deleteFile(remoteFileIdentifier: string): Promise { + const client = this.getBlockBlobClient(remoteFileIdentifier) + await client.delete() + } + + + private get serviceClient(): BlobServiceClient { + if (!isNil(this._serviceClient)) return this._serviceClient + + this._serviceClient = BlobServiceClient.fromConnectionString(BLOB_CONNECTION_STRING) + return this._serviceClient + } + + private get containerClient(): ContainerClient { + if (!isNil(this._containerClient)) return this._containerClient + + this._containerClient = this.serviceClient.getContainerClient(BLOB_CONTAINER) + return this._containerClient + } + + private getBlockBlobClient(remoteFileIdentifier: string): BlockBlobClient { + return this.containerClient.getBlockBlobClient(remoteFileIdentifier) + } +} + +export default BlobStorageIntegration diff --git a/api/src/integrations/index.ts b/api/src/integrations/index.ts index fbfd3561..16c3a725 100644 --- a/api/src/integrations/index.ts +++ b/api/src/integrations/index.ts @@ -1,13 +1,17 @@ import { auth0Integration } from "./auth0-integration" import { yukonGovernmentIntegration } from "./yukon-government-integration" +import { BlobStorageIntegration } from "./blob-storage-integration" + export { // force multi-line export auth0Integration, yukonGovernmentIntegration, + BlobStorageIntegration, } export default { auth0Integration, yukonGovernmentIntegration, + BlobStorageIntegration, } diff --git a/api/src/middlewares/better-form-data-body-parser-middleware.ts b/api/src/middlewares/better-form-data-body-parser-middleware.ts index 2b5309cc..a7b6b7c6 100644 --- a/api/src/middlewares/better-form-data-body-parser-middleware.ts +++ b/api/src/middlewares/better-form-data-body-parser-middleware.ts @@ -1,5 +1,6 @@ import { type NextFunction, type Request, type Response } from "express" -import enhancedQsDecoder from "@/utils/enhanced-qs-decoder" + +import objectValueDecoder from "@/utils/object-value-decoder" export function betterFormDataBodyParserMiddleware( req: Request, @@ -11,7 +12,7 @@ export function betterFormDataBodyParserMiddleware( req.body && typeof req.body === "object" ) { - req.body = enhancedQsDecoder(req.body) + req.body = objectValueDecoder(req.body) } next() } diff --git a/api/src/models/archive-item-information-sharing-agreement-access-grant.ts b/api/src/models/archive-item-information-sharing-agreement-access-grant.ts index 4c1a304b..7a47b1c2 100644 --- a/api/src/models/archive-item-information-sharing-agreement-access-grant.ts +++ b/api/src/models/archive-item-information-sharing-agreement-access-grant.ts @@ -2,9 +2,10 @@ import { DataTypes, InferAttributes, InferCreationAttributes } from "@sequelize/ import { Attribute, PrimaryKey, Table } from "@sequelize/core/decorators-legacy" import BaseModel from "@/models/base-model" +import { type InformationSharingAgreementAccessGrantAccessLevels } from "@/models/information-sharing-agreement-access-grant" // NOTE: table is actually a "view" added to make policy checks simpler -// See api/src/db/migrations/20250523215742_create-archive-item-information-sharing-agreement-access-grants-table.ts +// See api/src/db/migrations/20260227210312_update-archive-item-information-sharing-agreement-access-grants-view.ts @Table({ timestamps: false, paranoid: false, @@ -17,9 +18,21 @@ export class ArchiveItemInformationSharingAgreementAccessGrant extends BaseModel @PrimaryKey declare archiveItemId: number + @Attribute(DataTypes.INTEGER) + declare informationSharingAgreementId: number + @Attribute(DataTypes.INTEGER) @PrimaryKey - declare informationSharingAgreementAccessGrantId: number + declare accessGrantId: number + + @Attribute(DataTypes.INTEGER) + declare groupId: number + + @Attribute(DataTypes.INTEGER) + declare userId: number + + @Attribute(DataTypes.INTEGER) + declare accessLevel: InformationSharingAgreementAccessGrantAccessLevels } export default ArchiveItemInformationSharingAgreementAccessGrant diff --git a/api/src/models/archive-item.ts b/api/src/models/archive-item.ts index b5fd1615..31c040b7 100644 --- a/api/src/models/archive-item.ts +++ b/api/src/models/archive-item.ts @@ -29,13 +29,13 @@ import Category from "@/models/category" import ArchiveItemCategory from "@/models/archive-item-category" /** Keep in sync with web/src/api/users-api.ts */ -export enum SecurityLevel { +export enum SecurityLevels { LOW = 1, MEDIUM = 2, HIGH = 3, } -export enum ArchiveItemStatus { +export enum ArchiveItemStatuses { ACCEPTED = "Accepted", REVIEWED = "Reviewed", LOCKED = "Locked", @@ -46,8 +46,8 @@ export class ArchiveItem extends BaseModel< InferAttributes, InferCreationAttributes > { - static readonly SecurityLevel = SecurityLevel - static readonly ArchiveItemStatus = ArchiveItemStatus + static readonly Levels = SecurityLevels + static readonly Statuses = ArchiveItemStatuses @Attribute(DataTypes.INTEGER) @PrimaryKey @@ -61,6 +61,7 @@ export class ArchiveItem extends BaseModel< @Attribute(DataTypes.STRING(255)) declare decisionText: string | null + // TODO: rename this to "creatorId" and make it required @Attribute(DataTypes.INTEGER) declare userId: number | null @@ -85,21 +86,21 @@ export class ArchiveItem extends BaseModel< @NotNull @ValidateAttribute({ isIn: { - args: [Object.values(ArchiveItemStatus)], - msg: `Status must be one of ${Object.values(ArchiveItemStatus).join(", ")}`, + args: [Object.values(ArchiveItemStatuses)], + msg: `Status must be one of ${Object.values(ArchiveItemStatuses).join(", ")}`, }, }) - declare status: ArchiveItemStatus + declare status: ArchiveItemStatuses @Attribute(DataTypes.INTEGER) @NotNull @ValidateAttribute({ isIn: { - args: [Object.values(SecurityLevel)], - msg: `Security Level must be one of ${Object.values(SecurityLevel).join(", ")}`, + args: [Object.values(SecurityLevels)], + msg: `Security Level must be one of ${Object.values(SecurityLevels).join(", ")}`, }, }) - declare securityLevel: SecurityLevel + declare securityLevel: SecurityLevels @Attribute({ type: DataTypes.STRING(255), @@ -163,26 +164,20 @@ export class ArchiveItem extends BaseModel< declare archiveItemFileCount?: number // Helper functions - hasInformationSharingAgreementAccessGrantFor(userId: number): boolean { - if (isUndefined(this.informationSharingAgreementAccessGrants)) { - throw new Error( - "Expected informationSharingAgreementAccessGrants association to be pre-loaded." - ) + hasAccessGrantFor(userId: number): boolean { + if (isUndefined(this.accessGrants)) { + throw new Error("Expected accessGrants association to be pre-loaded.") } - return this.informationSharingAgreementAccessGrants.some( - (accessGrant) => accessGrant.userId === userId - ) + return this.accessGrants.some((accessGrant) => accessGrant.userId === userId) } - hasAdminInformationSharingAgreementAccessGrantFor(userId: number): boolean { - if (isUndefined(this.informationSharingAgreementAccessGrants)) { - throw new Error( - "Expected informationSharingAgreementAccessGrants association to be pre-loaded." - ) + hasAdminAccessGrantFor(userId: number): boolean { + if (isUndefined(this.accessGrants)) { + throw new Error("Expected accessGrants association to be pre-loaded.") } - return this.informationSharingAgreementAccessGrants.some( + return this.accessGrants.some( (accessGrant) => accessGrant.userId === userId && accessGrant.accessLevel === InformationSharingAgreementAccessGrant.AccessLevels.ADMIN @@ -229,20 +224,22 @@ export class ArchiveItem extends BaseModel< @BelongsToMany(() => InformationSharingAgreementAccessGrant, { through: () => ArchiveItemInformationSharingAgreementAccessGrant, foreignKey: "archiveItemId", - otherKey: "informationSharingAgreementAccessGrantId", + otherKey: "accessGrantId", inverse: "archiveItems", + throughAssociations: { + fromSource: "archiveItemAccessGrants", + toSource: "archiveItem", + fromTarget: "archiveItemAccessGrants", + toTarget: "informationSharingAgreementAccessGrant", + }, }) - declare informationSharingAgreementAccessGrants?: NonAttribute< - InformationSharingAgreementAccessGrant[] - > + declare accessGrants?: NonAttribute /** * Created by ArchiveItem.belongsToMany(InformationSharingAgreementAccessGrant), refers to a direct connection to a given InformationSharingAgreementAccessGrant * Populated by by { include: [{ association: "informationSharingAgreementAccessGrants", through: { attributes: [xxx] } }] } * See https://sequelize.org/docs/v7/querying/select-in-depth/#eager-loading-the-belongstomany-through-model */ - declare informationSharingAgreementAccessGrant?: NonAttribute< - InformationSharingAgreementAccessGrant[] - > + declare accessGrant?: NonAttribute // Scopes static establishScopes(): void { diff --git a/api/src/models/external-organization.ts b/api/src/models/external-organization.ts index 7db672e2..ef4fc7b0 100644 --- a/api/src/models/external-organization.ts +++ b/api/src/models/external-organization.ts @@ -70,6 +70,18 @@ export class ExternalOrganization extends BaseModel< }, } }) + + this.addScope("withUserId", (userId: number) => ({ + include: [ + { + association: "users", + attributes: [], + where: { + id: userId, + }, + }, + ], + })) } } diff --git a/api/src/models/information-sharing-agreement-access-grant.ts b/api/src/models/information-sharing-agreement-access-grant.ts index 7e120ed9..70deb97a 100644 --- a/api/src/models/information-sharing-agreement-access-grant.ts +++ b/api/src/models/information-sharing-agreement-access-grant.ts @@ -172,7 +172,7 @@ export class InformationSharingAgreementAccessGrant extends BaseModel< @HasMany(() => ArchiveItemInformationSharingAgreementAccessGrant, { foreignKey: { - name: "informationSharingAgreementAccessGrantId", + name: "accessGrantId", allowNull: false, }, inverse: "informationSharingAgreementAccessGrant", @@ -183,7 +183,7 @@ export class InformationSharingAgreementAccessGrant extends BaseModel< @BelongsToMany(() => InformationSharingAgreementAccessGrant, { through: () => InformationSharingAgreementAccessGrantSibling, - foreignKey: "informationSharingAgreementAccessGrantId", + foreignKey: "accessGrantId", otherKey: "informationSharingAgreementAccessGrantSiblingId", inverse: "siblingsOf", }) diff --git a/api/src/models/information-sharing-agreement.ts b/api/src/models/information-sharing-agreement.ts index 9c8a7a98..0ed670a0 100644 --- a/api/src/models/information-sharing-agreement.ts +++ b/api/src/models/information-sharing-agreement.ts @@ -342,6 +342,7 @@ export class InformationSharingAgreement extends BaseModel< information_sharing_agreement_archive_items WHERE archive_item_id = :archiveItemId + AND deleted_at IS NULL ) `, }, @@ -351,6 +352,26 @@ export class InformationSharingAgreement extends BaseModel< }, } }) + + this.addScope("notLinkedToAnyArchiveItem", () => { + return { + where: { + [Op.and]: sql` + NOT EXISTS ( + SELECT + 1 + FROM + information_sharing_agreement_archive_items + WHERE + information_sharing_agreement_archive_items.information_sharing_agreement_id = ${sql.attribute( + "id" + )} + AND information_sharing_agreement_archive_items.deleted_at IS NULL + ) + `, + }, + } + }) } } diff --git a/api/src/policies/archive-items-policy.ts b/api/src/policies/archive-items-policy.ts index 0805d23a..b9a76b54 100644 --- a/api/src/policies/archive-items-policy.ts +++ b/api/src/policies/archive-items-policy.ts @@ -9,7 +9,7 @@ import { PolicyFactory } from "@/policies/base-policy" export class ArchiveItemsPolicy extends PolicyFactory(ArchiveItem) { show(): boolean { if (this.user.id === this.record.userId) return true - if (this.record.hasInformationSharingAgreementAccessGrantFor(this.user.id)) return true + if (this.record.hasAccessGrantFor(this.user.id)) return true return false } @@ -20,14 +20,14 @@ export class ArchiveItemsPolicy extends PolicyFactory(ArchiveItem) { update(): boolean { if (this.user.id === this.record.userId) return true - if (this.record.hasAdminInformationSharingAgreementAccessGrantFor(this.user.id)) return true + if (this.record.hasAdminAccessGrantFor(this.user.id)) return true return false } destroy(): boolean { if (this.user.id === this.record.userId) return true - if (this.record.hasAdminInformationSharingAgreementAccessGrantFor(this.user.id)) return true + if (this.record.hasAdminAccessGrantFor(this.user.id)) return true return false } @@ -36,25 +36,27 @@ export class ArchiveItemsPolicy extends PolicyFactory(ArchiveItem) { const attributes: (keyof Attributes)[] = [ "title", "description", - "summary", "sharingPurpose", "confidentialityReceipt", "yukonFirstNations", "status", "securityLevel", "tags", - "submittedAt", ] - /* if (this.user.isSystemAdmin) { - attributes.push("email", "roles", "deactivatedAt") - } */ - return attributes } permittedAttributesForCreate(): Path[] { - return [...this.permittedAttributes()] + return [ + ...this.permittedAttributes(), + { + archiveItemCategoriesAttributes: ["categoryId"], + }, + { + archiveItemFilesAttributes: ["name", "path"], + }, + ] } static policyScope(user: User): FindOptions> { @@ -64,7 +66,7 @@ export class ArchiveItemsPolicy extends PolicyFactory(ArchiveItem) { archive_item_information_sharing_agreement_access_grants.archive_item_id FROM archive_item_information_sharing_agreement_access_grants - INNER JOIN information_sharing_agreement_access_grants ON archive_item_information_sharing_agreement_access_grants.information_sharing_agreement_access_grant_id = information_sharing_agreement_access_grants.id + INNER JOIN information_sharing_agreement_access_grants ON archive_item_information_sharing_agreement_access_grants.access_grant_id = information_sharing_agreement_access_grants.id WHERE information_sharing_agreement_access_grants.user_id = :userId ) diff --git a/api/src/policies/base-policy.ts b/api/src/policies/base-policy.ts index d8c38f81..64d95f08 100644 --- a/api/src/policies/base-policy.ts +++ b/api/src/policies/base-policy.ts @@ -4,6 +4,8 @@ import { User } from "@/models" import { Path, deepPick } from "@/utils/deep-pick" export type Actions = "show" | "create" | "update" | "destroy" +export type PolicyAsReference = Record + export const NO_RECORDS_SCOPE = Object.freeze({ where: literal("1 = 0") }) export const ALL_RECORDS_SCOPE = Object.freeze({}) @@ -79,7 +81,7 @@ export class BasePolicy { * * @returns a JSON representation of the policy */ - toJSON(): Record { + toJSON(): PolicyAsReference { return { show: this.show(), create: this.create(), diff --git a/api/src/policies/information-sharing-agreement-archive-item-policy.ts b/api/src/policies/information-sharing-agreement-archive-item-policy.ts index a5a441a4..3b555cf7 100644 --- a/api/src/policies/information-sharing-agreement-archive-item-policy.ts +++ b/api/src/policies/information-sharing-agreement-archive-item-policy.ts @@ -1,8 +1,8 @@ import { type Attributes, type FindOptions } from "@sequelize/core" -import { isUndefined } from "lodash" +import { isNil, isUndefined } from "lodash" import { type Path } from "@/utils/deep-pick" -import { InformationSharingAgreementArchiveItem, User } from "@/models" +import { InformationSharingAgreementArchiveItem, User, type InformationSharingAgreement } from "@/models" import { PolicyFactory } from "@/policies/base-policy" import InformationSharingAgreementPolicy from "@/policies/information-sharing-agreement-policy" @@ -16,19 +16,28 @@ export class InformationSharingAgreementArchiveItemPolicy extends PolicyFactory( } create(): boolean { - if (this.informationSharingAgreementPolicy.update()) return true + if (this.user.id === this.informationSharingAgreement.creatorId) return true + if (this.user.isSystemAdmin) return true + if (this.isAdminOfInternalGroup()) return true + if (this.isAdminOfExternalGroup()) return true return false } update(): boolean { - if (this.informationSharingAgreementPolicy.update()) return true + if (this.user.id === this.informationSharingAgreement.creatorId) return true + if (this.user.isSystemAdmin) return true + if (this.isAdminOfInternalGroup()) return true + if (this.isAdminOfExternalGroup()) return true return false } destroy(): boolean { - if (this.informationSharingAgreementPolicy.update()) return true + if (this.user.id === this.informationSharingAgreement.creatorId) return true + if (this.user.isSystemAdmin) return true + if (this.isAdminOfInternalGroup()) return true + if (this.isAdminOfExternalGroup()) return true return false } @@ -47,15 +56,7 @@ export class InformationSharingAgreementArchiveItemPolicy extends PolicyFactory( { association: "informationSharingAgreement", attributes: ["id"], - include: [ - { - association: "accessGrants", - attributes: [], - where: { - userId: user.id, - }, - }, - ], + ...InformationSharingAgreementPolicy.policyScope(user), required: true, }, ], @@ -70,6 +71,29 @@ export class InformationSharingAgreementArchiveItemPolicy extends PolicyFactory( return new InformationSharingAgreementPolicy(this.user, informationSharingAgreement) } + + private isAdminOfInternalGroup(): boolean { + const { internalGroupId } = this.informationSharingAgreement + if (isNil(internalGroupId)) return false + + return this.user.isGroupAdminOf(internalGroupId) + } + + private isAdminOfExternalGroup(): boolean { + const { externalGroupId } = this.informationSharingAgreement + if (isNil(externalGroupId)) return false + + return this.user.isGroupAdminOf(externalGroupId) + } + + private get informationSharingAgreement(): InformationSharingAgreement { + const { informationSharingAgreement } = this.record + if (isUndefined(informationSharingAgreement)) { + throw new Error("Expected information sharing agreement association to be pre-loaded") + } + + return informationSharingAgreement + } } export default InformationSharingAgreementArchiveItemPolicy diff --git a/api/src/policies/information-sharing-agreements/signed-state-policy.ts b/api/src/policies/information-sharing-agreements/signed-state-policy.ts index b770e22a..297bab3e 100644 --- a/api/src/policies/information-sharing-agreements/signed-state-policy.ts +++ b/api/src/policies/information-sharing-agreements/signed-state-policy.ts @@ -13,20 +13,10 @@ export class SignedStatePolicy extends GenericStatePolicy { } update(): boolean { - if (this.user.id === this.record.creatorId) return true - if (this.user.isSystemAdmin) return true - if (this.isAdminOfInternalGroup()) return true - if (this.isAdminOfExternalGroup()) return true - return false } destroy(): boolean { - if (this.user.id === this.record.creatorId) return true - if (this.user.isSystemAdmin) return true - if (this.isAdminOfInternalGroup()) return true - if (this.isAdminOfExternalGroup()) return true - return false } @@ -43,20 +33,6 @@ export class SignedStatePolicy extends GenericStatePolicy { return this.user.isMemberOfGroup(externalGroupId) } - - private isAdminOfInternalGroup(): boolean { - const { internalGroupId } = this.record - if (isNil(internalGroupId)) return false - - return this.user.isGroupAdminOf(internalGroupId) - } - - private isAdminOfExternalGroup(): boolean { - const { externalGroupId } = this.record - if (isNil(externalGroupId)) return false - - return this.user.isGroupAdminOf(externalGroupId) - } } export default SignedStatePolicy diff --git a/api/src/router.ts b/api/src/router.ts index 1cb0a3a9..932a7e7f 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -118,7 +118,7 @@ router .get(ArchiveItemsController.index) .post(ArchiveItemsController.create) router - .route("/api/archive-items/:id") + .route("/api/archive-items/:archiveItemId") .get(ArchiveItemsController.show) .patch(ArchiveItemsController.update) .delete(ArchiveItemsController.destroy) @@ -163,6 +163,9 @@ router "/api/information-sharing-agreements/:informationSharingAgreementId/generate-acknowledgement" ) .post(InformationSharingAgreements.GenerateAcknowledgementController.create) +router + .route("/api/information-sharing-agreements/:informationSharingAgreementId/archive-items") + .post(InformationSharingAgreements.ArchiveItemsController.create) router .route("/api/information-sharing-agreement-access-grants") diff --git a/api/src/serializers/archive-items/show-serializer.ts b/api/src/serializers/archive-items/show-serializer.ts index 673e5672..b34f52a6 100644 --- a/api/src/serializers/archive-items/show-serializer.ts +++ b/api/src/serializers/archive-items/show-serializer.ts @@ -9,7 +9,7 @@ import { type InformationSharingAgreementAccessGrantShowView } from "@/serialize import BaseSerializer from "@/serializers/base-serializer" import { type UserAsReference } from "@/serializers/users/reference-serializer" -export type ArchiveItemShowView = Pick< +export type ArchiveItemAsShow = Pick< ArchiveItem, | "id" | "isDecision" @@ -29,21 +29,19 @@ export type ArchiveItemShowView = Pick< | "updatedAt" > & { user?: UserAsReference - informationSharingAgreementAccessGrants?: InformationSharingAgreementAccessGrantShowView[] + accessGrants?: InformationSharingAgreementAccessGrantShowView[] categories?: CategoryIndexView[] } export class ShowSerializer extends BaseSerializer { - perform(): ArchiveItemShowView { - const { user, informationSharingAgreementAccessGrants, categories } = this.record + perform(): ArchiveItemAsShow { + const { user, accessGrants, categories } = this.record if (isUndefined(user)) { throw new Error("Expected user association to be preloaded") } - if (isUndefined(informationSharingAgreementAccessGrants)) { - throw new Error( - "Expected informationSharingAgreementAccessGrants association to be preloaded" - ) + if (isUndefined(accessGrants)) { + throw new Error("Expected accessGrants association to be preloaded") } if (isUndefined(categories)) { @@ -52,9 +50,7 @@ export class ShowSerializer extends BaseSerializer { const serializedUser = Users.ReferenceSerializer.perform(user) const serializedInformationSharingAgreementAccessGrants = - InformationSharingAgreementAccessGrants.ShowSerializer.perform( - informationSharingAgreementAccessGrants - ) + InformationSharingAgreementAccessGrants.ShowSerializer.perform(accessGrants) const serializedCategories = categories.map((category) => CategoryIndexSerializer.perform(category) ) @@ -78,7 +74,7 @@ export class ShowSerializer extends BaseSerializer { "updatedAt", ]), user: serializedUser, - informationSharingAgreementAccessGrants: serializedInformationSharingAgreementAccessGrants, + accessGrants: serializedInformationSharingAgreementAccessGrants, categories: serializedCategories, } } diff --git a/api/src/serializers/information-sharing-agreements/index-serializer.ts b/api/src/serializers/information-sharing-agreements/index-serializer.ts index 9398bce8..f61dd982 100644 --- a/api/src/serializers/information-sharing-agreements/index-serializer.ts +++ b/api/src/serializers/information-sharing-agreements/index-serializer.ts @@ -2,8 +2,10 @@ import { pick } from "lodash" import { formatDate } from "@/utils/formatters" -import { InformationSharingAgreement } from "@/models" +import { type PolicyAsReference } from "@/policies/base-policy" +import { InformationSharingAgreement, type User } from "@/models" import BaseSerializer from "@/serializers/base-serializer" +import { InformationSharingAgreementPolicy } from "@/policies" export type InformationSharingAgreementAsIndex = Pick< InformationSharingAgreement, @@ -20,13 +22,24 @@ export type InformationSharingAgreementAsIndex = Pick< > & { startDate: string | null endDate: string | null +} & { + policy: PolicyAsReference } export class IndexSerializer extends BaseSerializer { + constructor( + protected record: InformationSharingAgreement, + protected currentUser: User + ) { + super(record) + } + perform(): InformationSharingAgreementAsIndex { const { startDate, endDate } = this.record const formattedStartDate = formatDate(startDate) const formattedEndDate = formatDate(endDate) + + const serializedPolicy = this.serializePolicy(this.record, this.currentUser) return { ...pick(this.record, [ "id", @@ -42,8 +55,16 @@ export class IndexSerializer extends BaseSerializer ]), startDate: formattedStartDate, endDate: formattedEndDate, + policy: serializedPolicy, } } + + private serializePolicy( + record: InformationSharingAgreement, + currentUser: User + ): PolicyAsReference { + return new InformationSharingAgreementPolicy(currentUser, record).toJSON() + } } export default IndexSerializer diff --git a/api/src/services/archive-item-files/create-service.ts b/api/src/services/archive-item-files/create-service.ts new file mode 100644 index 00000000..cdc0d5d2 --- /dev/null +++ b/api/src/services/archive-item-files/create-service.ts @@ -0,0 +1,65 @@ +import { statSync } from "fs" + +import { Attributes } from "@sequelize/core" +import { isNil } from "lodash" + +import { BlobStorageIntegration } from "@/integrations" + +import { ArchiveItemFile } from "@/models" +import BaseService from "@/services/base-service" + +export type ArchiveItemFileCreationAttributes = Partial> + +export class CreateService extends BaseService { + constructor( + private filePath: string, + private fileName: string, + private attributes: ArchiveItemFileCreationAttributes + ) { + super() + } + + async perform(): Promise { + const { archiveItemId, ...optionalAttributes } = this.attributes + + if (isNil(archiveItemId)) { + throw new Error("Archive item ID is required") + } + + const originalKey = await this.uploadFileToAzureBlobStorage(this.filePath) + const mimeType = await this.determineMimeType(this.filePath) + const fileSize = this.determineFileSize(this.filePath) + + const archiveItemFile = await ArchiveItemFile.create({ + ...optionalAttributes, + archiveItemId, + originalFileName: this.fileName, + originalFileSize: fileSize, + originalMimeType: mimeType, + originalKey, + }) + return archiveItemFile + } + + private async uploadFileToAzureBlobStorage(filePath: string): Promise { + return BlobStorageIntegration.uploadFile(filePath) + } + + private determineFileSize(filePath: string): number { + const stats = statSync(filePath) + return stats.size + } + + private async determineMimeType(filePath: string): Promise { + const { fileTypeFromFile } = await import("file-type") + + const fileTypeResult = await fileTypeFromFile(filePath) + if (isNil(fileTypeResult)) { + return "application/octet-stream" + } + + return fileTypeResult.mime + } +} + +export default CreateService diff --git a/api/src/services/archive-item-files/destroy-service.ts b/api/src/services/archive-item-files/destroy-service.ts new file mode 100644 index 00000000..d22b6649 --- /dev/null +++ b/api/src/services/archive-item-files/destroy-service.ts @@ -0,0 +1,58 @@ +import { isNil } from "lodash" + +import logger from "@/utils/logger" + +import { BlobStorageIntegration } from "@/integrations" + +import { ArchiveItemFile, User } from "@/models" +import BaseService from "@/services/base-service" + +/** + * Destroy service for ArchiveItemFile records. + * + * NOTE: File cleanup operations (blob storage deletion) are non-reversible regardless + * of transaction effects. Once files are deleted from blob storage, they cannot be + * recovered even if the database transaction rolls back. + */ +export class DestroyService extends BaseService { + constructor( + private archiveItemFile: ArchiveItemFile, + private currentUser: User + ) { + super() + } + + async perform(): Promise { + const { originalKey, pdfKey } = this.archiveItemFile + + if (!isNil(originalKey)) { + await this.deleteOriginalFile(originalKey) + } + + if (!isNil(pdfKey)) { + await this.deletePdfFile(pdfKey) + } + + await this.archiveItemFile.destroy() + } + + private async deleteOriginalFile(originalKey: string): Promise { + try { + await BlobStorageIntegration.deleteFile(originalKey) + } catch (error) { + logger.warn(`Failed to delete original file from blob storage: ${originalKey}: ${error}`, { + error, + }) + } + } + + private async deletePdfFile(pdfKey: string): Promise { + try { + await BlobStorageIntegration.deleteFile(pdfKey) + } catch (error) { + logger.warn(`Failed to delete PDF file from blob storage: ${pdfKey}: ${error}`, { error }) + } + } +} + +export default DestroyService diff --git a/api/src/services/archive-item-files/index.ts b/api/src/services/archive-item-files/index.ts new file mode 100644 index 00000000..93eb763b --- /dev/null +++ b/api/src/services/archive-item-files/index.ts @@ -0,0 +1,2 @@ +export { CreateService } from "./create-service" +export { DestroyService } from "./destroy-service" diff --git a/api/src/services/archive-items/create-service.ts b/api/src/services/archive-items/create-service.ts index 46ccf155..9c05e5b1 100644 --- a/api/src/services/archive-items/create-service.ts +++ b/api/src/services/archive-items/create-service.ts @@ -5,7 +5,7 @@ import cache from "@/db/cache-client" import db, { ArchiveItem, ArchiveItemCategory, ArchiveItemFile, User } from "@/models" import BaseService from "@/services/base-service" -import { ArchiveItemStatus } from "@/models/archive-item" +import { ArchiveItemStatuses } from "@/models/archive-item" import { FileStorageService } from "@/services/file-storage-service" export type ArchiveItemCreationAttributes = Partial> & { @@ -24,7 +24,7 @@ export class CreateService extends BaseService { async perform(): Promise { const { title, securityLevel, confidentialityReceipt, ...optionalAttributes } = this.attributes - const status = ArchiveItemStatus.ACCEPTED + const status = ArchiveItemStatuses.ACCEPTED if (isNil(title)) { throw new Error("Title is required") @@ -98,7 +98,7 @@ export class CreateService extends BaseService { "user", { association: "categories", through: { attributes: [] } }, { - association: "informationSharingAgreementAccessGrants", + association: "accessGrants", through: { // NOTE: suppressing through model attributes as their names are too long attributes: [], diff --git a/api/src/services/archive-items/destroy-service.ts b/api/src/services/archive-items/destroy-service.ts new file mode 100644 index 00000000..876f340f --- /dev/null +++ b/api/src/services/archive-items/destroy-service.ts @@ -0,0 +1,92 @@ +import db, { + ArchiveItem, + ArchiveItemAudit, + ArchiveItemCategory, + ArchiveItemFile, + InformationSharingAgreementArchiveItem, + User, +} from "@/models" +import BaseService from "@/services/base-service" +import { ArchiveItemFiles } from "@/services" + +export class DestroyService extends BaseService { + constructor( + private archiveItem: ArchiveItem, + private currentUser: User + ) { + super() + } + + async perform(): Promise { + const { id: archiveItemId, title } = this.archiveItem + const { displayName } = this.currentUser + + return db.transaction(async () => { + await this.removeChildEntities(archiveItemId) + + await this.archiveItem.destroy() + + await this.trackArchiveItemDestroyEvent(archiveItemId, title, displayName) + }) + } + + private async removeChildEntities(archiveItemId: number): Promise { + await this.removeInformationSharingAgreementLinks(archiveItemId) + await this.removeCategories(archiveItemId) + await this.removeFiles(archiveItemId) + await this.removeAuditTrail(archiveItemId) + } + + private async removeInformationSharingAgreementLinks(archiveItemId: number): Promise { + await InformationSharingAgreementArchiveItem.destroy({ + where: { + archiveItemId, + }, + }) + } + + private async removeCategories(archiveItemId: number): Promise { + await ArchiveItemCategory.destroy({ + where: { + archiveItemId, + }, + }) + } + + private async removeFiles(archiveItemId: number): Promise { + await ArchiveItemFile.findEach( + { + where: { + archiveItemId, + }, + }, + async (archiveItemFile) => { + await ArchiveItemFiles.DestroyService.perform(archiveItemFile, this.currentUser) + } + ) + } + + // TODO: Consider if we want to keep the audit trail even after the item is deleted. + private async removeAuditTrail(archiveItemId: number): Promise { + await ArchiveItemAudit.destroy({ + where: { + archiveItemId, + }, + }) + } + + private async trackArchiveItemDestroyEvent( + archiveItemId: number, + title: string, + displayName: string + ): Promise { + await ArchiveItemAudit.create({ + archiveItemId, + action: "Deleted", + userId: this.currentUser.id, + description: `${displayName} deleted archive item "${title}"`, + }) + } +} + +export default DestroyService diff --git a/api/src/services/archive-items/index.ts b/api/src/services/archive-items/index.ts index e323ee50..93eb763b 100644 --- a/api/src/services/archive-items/index.ts +++ b/api/src/services/archive-items/index.ts @@ -1 +1,2 @@ export { CreateService } from "./create-service" +export { DestroyService } from "./destroy-service" diff --git a/api/src/services/file-storage-service.ts b/api/src/services/file-storage-service.ts index 76a319b2..44f7af9c 100644 --- a/api/src/services/file-storage-service.ts +++ b/api/src/services/file-storage-service.ts @@ -2,6 +2,7 @@ import { v4 as uuidV4 } from "uuid" import { BlobServiceClient } from "@azure/storage-blob" import { BLOB_CONNECTION_STRING, BLOB_CONTAINER } from "@/config" +/** @deprecated Prefer using BlobStorageIntegration from "@/integrations/blob-storage-integration" */ export class FileStorageService { private serviceClient private containerClient diff --git a/api/src/services/groups/destroy-service.ts b/api/src/services/groups/destroy-service.ts index 3b20b414..f132cf42 100644 --- a/api/src/services/groups/destroy-service.ts +++ b/api/src/services/groups/destroy-service.ts @@ -1,4 +1,6 @@ -import db, { Group, UserGroup, User } from "@/models" +import { Op } from "@sequelize/core" + +import db, { Group, UserGroup, User, InformationSharingAgreement } from "@/models" import BaseService from "@/services/base-service" import { UserGroups } from "@/services" @@ -11,17 +13,41 @@ export class DestroyService extends BaseService { } async perform() { + const { id: groupId } = this.group return db.transaction(async () => { - await this.cleanupUserGroups() + await this.assertNoDependentEntitiesExist(groupId) + await this.cleanupUserGroups(groupId) await this.group.destroy() }) } - private async cleanupUserGroups() { + private async assertNoDependentEntitiesExist(groupId: number) { + await this.assertNoDependentInformationSharingAgreementsExist(groupId) + } + + private async assertNoDependentInformationSharingAgreementsExist(groupId: number) { + const dependentInformationSharingAgreementsCount = await InformationSharingAgreement.count({ + where: { + [Op.or]: [ + { + externalGroupId: groupId, + }, + { + internalGroupId: groupId, + }, + ], + }, + }) + if (dependentInformationSharingAgreementsCount > 0) { + throw new Error("Groups with dependent information sharing agreements cannot be deleted") + } + } + + private async cleanupUserGroups(groupId: number) { await UserGroup.findEach( { where: { - groupId: this.group.id, + groupId, }, include: ["user", "group"], }, diff --git a/api/src/services/index.ts b/api/src/services/index.ts index e7831e25..24f9fa3c 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -1,5 +1,7 @@ export { FileStorageService } from "./file-storage-service" +export * as ArchiveItemFiles from "./archive-item-files" +export * as ArchiveItems from "./archive-items" export * as Attachments from "./attachments" export * as Groups from "./groups" export * as InformationSharingAgreementAccessGrants from "./information-sharing-agreement-access-grants" diff --git a/api/src/services/information-sharing-agreements/archive-items/create-service.ts b/api/src/services/information-sharing-agreements/archive-items/create-service.ts new file mode 100644 index 00000000..c552d690 --- /dev/null +++ b/api/src/services/information-sharing-agreements/archive-items/create-service.ts @@ -0,0 +1,125 @@ +import { type CreationAttributes } from "@sequelize/core" +import { isEmpty, isNil } from "lodash" + +import db, { + ArchiveItem, + ArchiveItemCategory, + InformationSharingAgreement, + InformationSharingAgreementArchiveItem, + User, +} from "@/models" +import BaseService from "@/services/base-service" +import { ArchiveItemFiles } from "@/services" + +export type ArchiveItemCategoriesAttributes = { + categoryId: number +} + +export type ArchiveItemFilesAttributes = { + name: string + path: string +} + +export type ArchiveItemCreationAttributes = Partial> & { + archiveItemCategoriesAttributes?: ArchiveItemCategoriesAttributes[] + archiveItemFilesAttributes?: ArchiveItemFilesAttributes[] +} + +export class CreateService extends BaseService { + constructor( + private informationSharingAgreement: InformationSharingAgreement, + private attributes: ArchiveItemCreationAttributes, + private currentUser: User + ) { + super() + } + + async perform(): Promise { + const { + title, + status, + isDecision, + confidentialityReceipt, + securityLevel, + archiveItemCategoriesAttributes, + archiveItemFilesAttributes, + ...optionalAttributes + } = this.attributes + + if (isNil(title) || isEmpty(title)) { + throw new Error("Title is required") + } + + if (isNil(confidentialityReceipt) || confidentialityReceipt !== true) { + throw new Error("Confidentiality receipt is required, and must be true") + } + + if (isNil(securityLevel)) { + throw new Error("Security level is required") + } + + // TODO: remove these from the model if they are unused by the UI. + const isDecisionOrFallback = isDecision ?? false + const statusOrFallback = status ?? ArchiveItem.Statuses.ACCEPTED + + return db.transaction(async () => { + const archiveItem = await ArchiveItem.create({ + ...optionalAttributes, + title, + confidentialityReceipt, + isDecision: isDecisionOrFallback, + status: statusOrFallback, + securityLevel, + userId: this.currentUser.id, + }) + + await this.linkArchiveItemToInformationSharingAgreement(archiveItem.id) + + if (!isNil(archiveItemFilesAttributes)) { + await this.uploadFilesForArchiveItem(archiveItem.id, archiveItemFilesAttributes) + } + + await this.assignCategoriesToArchiveItem(archiveItem.id, archiveItemCategoriesAttributes) + + return archiveItem.reload({ + include: ["categories", "accessGrants", "user"], + }) + }) + } + + private async uploadFilesForArchiveItem( + archiveItemId: number, + archiveItemFilesAttributes: ArchiveItemFilesAttributes[] + ): Promise { + for (const { name, path } of archiveItemFilesAttributes) { + await ArchiveItemFiles.CreateService.perform(path, name, { archiveItemId }) + } + } + + private async linkArchiveItemToInformationSharingAgreement(archiveItemId: number): Promise { + await InformationSharingAgreementArchiveItem.create({ + informationSharingAgreementId: this.informationSharingAgreement.id, + archiveItemId: archiveItemId, + creatorId: this.currentUser.id, + }) + } + + private async assignCategoriesToArchiveItem( + archiveItemId: number, + archiveItemCategoriesAttributes: { categoryId: number }[] | undefined + ): Promise { + if (isNil(archiveItemCategoriesAttributes) || isEmpty(archiveItemCategoriesAttributes)) { + return + } + + const archiveItemCategoriesAttributesWithArchiveItemId = archiveItemCategoriesAttributes.map( + ({ categoryId }) => ({ + archiveItemId, + categoryId, + }) + ) + await ArchiveItemCategory.bulkCreate(archiveItemCategoriesAttributesWithArchiveItemId) + } +} + +export default CreateService diff --git a/api/src/services/information-sharing-agreements/archive-items/index.ts b/api/src/services/information-sharing-agreements/archive-items/index.ts new file mode 100644 index 00000000..e323ee50 --- /dev/null +++ b/api/src/services/information-sharing-agreements/archive-items/index.ts @@ -0,0 +1 @@ +export { CreateService } from "./create-service" diff --git a/api/src/services/information-sharing-agreements/destroy-groups-service.ts b/api/src/services/information-sharing-agreements/destroy-groups-service.ts index a769d411..b1af815c 100644 --- a/api/src/services/information-sharing-agreements/destroy-groups-service.ts +++ b/api/src/services/information-sharing-agreements/destroy-groups-service.ts @@ -1,8 +1,13 @@ import { isNil } from "lodash" -import db, { Group, InformationSharingAgreement, User } from "@/models" +import db, { + Group, + InformationSharingAgreement, + InformationSharingAgreementAccessGrant, + UserGroup, + User, +} from "@/models" import BaseService from "@/services/base-service" -import { Groups } from "@/services" export class DestroyGroupsService extends BaseService { constructor( @@ -13,7 +18,12 @@ export class DestroyGroupsService extends BaseService { } async perform(): Promise { - const { externalGroupId, internalGroupId } = this.informationSharingAgreement + const { + id: informationSharingAgreementId, + externalGroupId, + internalGroupId, + } = this.informationSharingAgreement + if (isNil(externalGroupId)) { throw new Error("External group ID is required") } @@ -23,16 +33,52 @@ export class DestroyGroupsService extends BaseService { } return db.transaction(async () => { - await Group.findEach( - { - where: { - id: [externalGroupId, internalGroupId], - }, - }, - async (group) => { - await Groups.DestroyService.perform(group, this.currentUser) - } + await this.removeChildEntities( + informationSharingAgreementId, + externalGroupId, + internalGroupId ) + await this.informationSharingAgreement.update({ + externalGroupId: null, + internalGroupId: null, + }) + }) + } + + private async removeChildEntities( + informationSharingAgreementId: number, + externalGroupId: number, + internalGroupId: number + ): Promise { + await this.removeChildAccessGrants(informationSharingAgreementId) + await this.removeChildUserGroups(externalGroupId, internalGroupId) + await this.removeChildGroup(externalGroupId, internalGroupId) + } + + private async removeChildAccessGrants(informationSharingAgreementId: number): Promise { + await InformationSharingAgreementAccessGrant.destroy({ + where: { + informationSharingAgreementId, + }, + }) + } + + private async removeChildUserGroups( + externalGroupId: number, + internalGroupId: number + ): Promise { + await UserGroup.destroy({ + where: { + groupId: [externalGroupId, internalGroupId], + }, + }) + } + + private async removeChildGroup(externalGroupId: number, internalGroupId: number): Promise { + await Group.destroy({ + where: { + id: [externalGroupId, internalGroupId], + }, }) } } diff --git a/api/src/services/information-sharing-agreements/index.ts b/api/src/services/information-sharing-agreements/index.ts index 98bd73fd..f15a2e06 100644 --- a/api/src/services/information-sharing-agreements/index.ts +++ b/api/src/services/information-sharing-agreements/index.ts @@ -10,4 +10,5 @@ export { CreateGroupsService } from "./create-groups-service" export { DestroyGroupsService } from "./destroy-groups-service" // Bundled exports +export * as ArchiveItems from "./archive-items" export * as GenerateAcknowledgement from "./generate-acknowledgement" diff --git a/api/src/utils/object-value-decoder.ts b/api/src/utils/object-value-decoder.ts new file mode 100644 index 00000000..626b9be3 --- /dev/null +++ b/api/src/utils/object-value-decoder.ts @@ -0,0 +1,14 @@ +import { cloneDeepWith } from "lodash" + +export function objectValueDecoder(value: T): T { + return cloneDeepWith(value, (value) => { + if (typeof value === "string") { + if (value === "true") return true + if (value === "false") return false + } + + return undefined // undefined to let lodash handle the rest without changes + }) +} + +export default objectValueDecoder diff --git a/api/tests/utils/object-value-decoder.test.ts b/api/tests/utils/object-value-decoder.test.ts new file mode 100644 index 00000000..c2485688 --- /dev/null +++ b/api/tests/utils/object-value-decoder.test.ts @@ -0,0 +1,153 @@ +import objectValueDecoder from "@/utils/object-value-decoder" + +describe("api/src/utils/object-value-decoder.ts", () => { + describe(".objectValueDecoder", () => { + test("when given a simple object with string booleans, converts them to actual booleans", () => { + // Arrange + const object = { + active: "true", + visible: "false", + name: "test", + } + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({ + active: true, + visible: false, + name: "test", + }) + }) + + test("when given a nested object with string booleans, converts them recursively", () => { + // Arrange + const object = { + user: { + active: "true", + profile: { + visible: "false", + verified: "true", + }, + }, + name: "test", + } + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({ + user: { + active: true, + profile: { + visible: false, + verified: true, + }, + }, + name: "test", + }) + }) + + test("when given an array with string booleans, converts them recursively", () => { + // Arrange + const object = { + categories: [ + { + active: "true", + name: "Category 1", + }, + { + active: "false", + name: "Category 2", + }, + ], + } + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({ + categories: [ + { + active: true, + name: "Category 1", + }, + { + active: false, + name: "Category 2", + }, + ], + }) + }) + + test("when given mixed types, preserves non-string values", () => { + // Arrange + const object = { + active: "true", + count: 42, + enabled: false, + name: "test", + items: [], + metadata: null, + } + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({ + active: true, + count: 42, + enabled: false, + name: "test", + items: [], + metadata: null, + }) + }) + + test("when given boolean-like strings that are not exact matches, preserves them", () => { + // Arrange + const object = { + truthy: "True", + falsy: "False", + maybe: "TRUE", + perhaps: "FALSE", + notBoolean: "yes", + } + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({ + truthy: "True", + falsy: "False", + maybe: "TRUE", + perhaps: "FALSE", + notBoolean: "yes", + }) + }) + + test("when given an empty object, returns an empty object", () => { + // Arrange + const object = {} + + // Act + const result = objectValueDecoder(object) + + // Assert + expect(result).toEqual({}) + }) + + test("when given null, returns null", () => { + // Arrange + const object = null + + // Act & Assert + expect(() => objectValueDecoder(object)).not.toThrow() + }) + }) +}) diff --git a/archiver/src/jobs/pdf-converter-job.ts b/archiver/src/jobs/pdf-converter-job.ts index c8a0c3b5..290d05a7 100644 --- a/archiver/src/jobs/pdf-converter-job.ts +++ b/archiver/src/jobs/pdf-converter-job.ts @@ -16,11 +16,11 @@ export class PDFConverterJob { constructor() {} async run(statDate: Date) { - logger.info("Running PDF Converter Job", statDate) const toConvert = await cache.getKeysByPattern(`CONVERT_`) const fileStore = new FileStorageService() for (const key of toConvert) { + logger.info(`Processing PDF conversion for key: ${key} on ${statDate}`) const data = await cache.getValue(key) if (isNil(data)) return diff --git a/web/src/api/archive-items-api.ts b/web/src/api/archive-items-api.ts index 234bfba3..1f95c9a6 100644 --- a/web/src/api/archive-items-api.ts +++ b/web/src/api/archive-items-api.ts @@ -45,9 +45,9 @@ export type ArchiveItem = { categories: Category[] | null } -export type ArchiveItemShowView = ArchiveItem & { +export type ArchiveItemAsShow = ArchiveItem & { user: User - informationSharingAgreementAccessGrants: InformationSharingAgreementAccessGrant[] + accessGrants: InformationSharingAgreementAccessGrant[] } export type ArchiveItemCreate = { @@ -89,14 +89,14 @@ export const archiveItemsApi = { return data }, async get(archiveItemId: number): Promise<{ - archiveItem: ArchiveItemShowView + archiveItem: ArchiveItemAsShow policy: Policy }> { const { data } = await http.get(`/api/archive-items/${archiveItemId}`) return data }, - async create(attributes: FormData | GenericFormData | ArchiveItemCreate): Promise<{ - archiveItem: ArchiveItemShowView + async create(attributes: FormData | GenericFormData | Partial): Promise<{ + archiveItem: ArchiveItemAsShow }> { const { data } = await http.post("/api/archive-items", attributes, { headers: { "Content-Type": "multipart/form-data" }, @@ -107,7 +107,7 @@ export const archiveItemsApi = { archiveItemId: number, attributes: Partial ): Promise<{ - archiveItem: ArchiveItemShowView + archiveItem: ArchiveItemAsShow }> { const { data } = await http.patch(`/api/archive-items/${archiveItemId}`, attributes) return data @@ -117,6 +117,7 @@ export const archiveItemsApi = { return data }, + // Special Actions async download(archiveItemId: number, fileId: number, getProtected: boolean) { const { data } = await http.get( `/api/archive-items/${archiveItemId}/files/${fileId}${getProtected ? "?format=protected" : ""}`, diff --git a/web/src/api/external-organizations-api.ts b/web/src/api/external-organizations-api.ts index 45183116..0aaf091c 100644 --- a/web/src/api/external-organizations-api.ts +++ b/web/src/api/external-organizations-api.ts @@ -40,6 +40,7 @@ export type ExternalOrganizationWhereOptions = WhereOptions export type ExternalOrganizationQueryOptions = QueryOptions< diff --git a/web/src/api/index.ts b/web/src/api/index.ts index d7b6ca05..9f6ba088 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,14 +1,17 @@ import * as Downloads from "./downloads" -import * as yukonGovernmentDirectory from "./yukon-government-directory" +import * as InformationSharingAgreements from "./information-sharing-agreements" +import * as YukonGovernmentDirectory from "./yukon-government-directory" export { // this comment ensures line wrap Downloads, - yukonGovernmentDirectory, + InformationSharingAgreements, + YukonGovernmentDirectory, } export default { // this comment ensures line wrap Downloads, - yukonGovernmentDirectory, + InformationSharingAgreements, + YukonGovernmentDirectory, } diff --git a/web/src/api/information-sharing-agreements-api.ts b/web/src/api/information-sharing-agreements-api.ts index 8e009d82..611bdefd 100644 --- a/web/src/api/information-sharing-agreements-api.ts +++ b/web/src/api/information-sharing-agreements-api.ts @@ -94,6 +94,8 @@ export type InformationSharingAgreementAsIndex = Pick< > & { startDate: string | null endDate: string | null +} & { + policy: Policy } /** Keep in sync with api/src/serializers/information-sharing-agreements/show-serializer.ts */ @@ -196,6 +198,7 @@ export type InformationSharingAgreementWhereOptions = WhereOptions< export type InformationSharingAgreementFiltersOptions = FiltersOptions<{ search: string | string[] notAssociatedWithArchiveItem: number + notLinkedToAnyArchiveItem: boolean }> export type InformationSharingAgreementQueryOptions = QueryOptions< diff --git a/web/src/api/information-sharing-agreements/archive-items-api.ts b/web/src/api/information-sharing-agreements/archive-items-api.ts new file mode 100644 index 00000000..dbeed678 --- /dev/null +++ b/web/src/api/information-sharing-agreements/archive-items-api.ts @@ -0,0 +1,43 @@ +import http from "@/api/http-client" +import { type ArchiveItemAsShow } from "@/api/archive-items-api" +import { SecurityLevel } from "@/api/archive-items-api" + +export type ArchiveItemCreationAttributes = { + title: string + description: string | null + sharingPurpose: string | null + confidentialityReceipt: boolean + yukonFirstNations: string[] + securityLevel: SecurityLevel + tags: string[] +} & { + archiveItemCategoriesAttributes: { + categoryId: number + }[] +} + +export const archiveItemsApi = { + async create( + informationSharingAgreementId: number, + attributes: Partial, + files: File[] + ): Promise<{ + archiveItem: ArchiveItemAsShow + }> { + const { data } = await http.post( + `/api/information-sharing-agreements/${informationSharingAgreementId}/archive-items`, + { + ...attributes, + archiveItemFilesAttributes: files, + }, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ) + return data + }, +} + +export default archiveItemsApi diff --git a/web/src/api/information-sharing-agreements/index.ts b/web/src/api/information-sharing-agreements/index.ts new file mode 100644 index 00000000..689adaba --- /dev/null +++ b/web/src/api/information-sharing-agreements/index.ts @@ -0,0 +1 @@ +export { archiveItemsApi } from "./archive-items-api" diff --git a/web/src/components/archive-items/ArchiveItemCard.vue b/web/src/components/archive-items/ArchiveItemCard.vue index f056d757..39b21071 100644 --- a/web/src/components/archive-items/ArchiveItemCard.vue +++ b/web/src/components/archive-items/ArchiveItemCard.vue @@ -7,7 +7,21 @@ v-else class="border" > - + diff --git a/web/src/components/information-sharing-agreements/InformationSharingAgreementsEditDataTableServer.vue b/web/src/components/information-sharing-agreements/InformationSharingAgreementsEditDataTableServer.vue index 0f16d287..3459f694 100644 --- a/web/src/components/information-sharing-agreements/InformationSharingAgreementsEditDataTableServer.vue +++ b/web/src/components/information-sharing-agreements/InformationSharingAgreementsEditDataTableServer.vue @@ -35,8 +35,8 @@ diff --git a/web/src/components/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemCreateForm.vue b/web/src/components/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemCreateForm.vue new file mode 100644 index 00000000..130cfd57 --- /dev/null +++ b/web/src/components/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemCreateForm.vue @@ -0,0 +1,275 @@ + + + diff --git a/web/src/components/information-sharing-agreements/draft/InformationSharingAgreementDraftActionsMenu.vue b/web/src/components/information-sharing-agreements/draft/InformationSharingAgreementDraftActionsMenu.vue new file mode 100644 index 00000000..536b0a29 --- /dev/null +++ b/web/src/components/information-sharing-agreements/draft/InformationSharingAgreementDraftActionsMenu.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/web/src/components/information-sharing-agreements/signed/InformationSharingAgreementSignedActionsMenu.vue b/web/src/components/information-sharing-agreements/signed/InformationSharingAgreementSignedActionsMenu.vue new file mode 100644 index 00000000..cf974b38 --- /dev/null +++ b/web/src/components/information-sharing-agreements/signed/InformationSharingAgreementSignedActionsMenu.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/web/src/pages/archive-items/ArchiveItemInformationSharingAgreementsPage.vue b/web/src/pages/archive-items/ArchiveItemInformationSharingAgreementsPage.vue index b38f588b..c4804d2a 100644 --- a/web/src/pages/archive-items/ArchiveItemInformationSharingAgreementsPage.vue +++ b/web/src/pages/archive-items/ArchiveItemInformationSharingAgreementsPage.vue @@ -2,15 +2,13 @@ @@ -27,8 +25,10 @@ diff --git a/web/src/pages/information-sharing-agreements/InformationSharingAgreementPage.vue b/web/src/pages/information-sharing-agreements/InformationSharingAgreementPage.vue index 216a1c4e..7d9878ca 100644 --- a/web/src/pages/information-sharing-agreements/InformationSharingAgreementPage.vue +++ b/web/src/pages/information-sharing-agreements/InformationSharingAgreementPage.vue @@ -51,88 +51,22 @@ />
-
- - -
+ -
- - Edit Agreement - - - Back to List - -
+ + Back to List +
@@ -144,17 +78,13 @@ import { computed } from "vue" import { isNil } from "lodash" import useBreadcrumbs, { BASE_CRUMB } from "@/use/use-breadcrumbs" -import useInformationSharingAgreement, { - InformationSharingAgreementStatuses, -} from "@/use/use-information-sharing-agreement" +import useInformationSharingAgreement from "@/use/use-information-sharing-agreement" import InformationSharingAgreementAccessCard from "@/components/information-sharing-agreements/InformationSharingAgreementAccessCard.vue" +import InformationSharingAgreementActionsMenu from "@/components/information-sharing-agreements/InformationSharingAgreementActionsMenu.vue" import InformationSharingAgreementBasicInformationCard from "@/components/information-sharing-agreements/InformationSharingAgreementBasicInformationCard.vue" import InformationSharingAgreementConfidentialityCard from "@/components/information-sharing-agreements/InformationSharingAgreementConfidentialityCard.vue" -import InformationSharingAgreementDownloadDraftButton from "@/components/information-sharing-agreements/InformationSharingAgreementDownloadDraftButton.vue" -import InformationSharingAgreementDownloadSignedAcknowledgementButton from "@/components/information-sharing-agreements/InformationSharingAgreementDownloadSignedAcknowledgementButton.vue" import InformationSharingAgreementDurationCard from "@/components/information-sharing-agreements/InformationSharingAgreementDurationCard.vue" -import InformationSharingAgreementRevertToDraftDialog from "@/components/information-sharing-agreements/InformationSharingAgreementRevertToDraftDialog.vue" const props = defineProps<{ informationSharingAgreementId: string @@ -163,7 +93,7 @@ const props = defineProps<{ const informationSharingAgreementIdAsNumber = computed(() => parseInt(props.informationSharingAgreementId) ) -const { informationSharingAgreement, policy, refresh } = useInformationSharingAgreement( +const { informationSharingAgreement, refresh } = useInformationSharingAgreement( informationSharingAgreementIdAsNumber ) diff --git a/web/src/pages/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemNewPage.vue b/web/src/pages/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemNewPage.vue new file mode 100644 index 00000000..26b0b1ec --- /dev/null +++ b/web/src/pages/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemNewPage.vue @@ -0,0 +1,38 @@ + + + diff --git a/web/src/routes.ts b/web/src/routes.ts index 4486d181..ac9ff946 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -108,6 +108,13 @@ const routes: RouteRecordRaw[] = [ }, ], }, + { + path: "information-sharing-agreements/:informationSharingAgreementId/archive-items/new", + name: "information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemNewPage", + component: () => + import("@/pages/information-sharing-agreements/archive-items/InformationSharingAgreementArchiveItemNewPage.vue"), + props: true, + }, { path: "administration", children: [ diff --git a/web/src/use/use-archive-item.ts b/web/src/use/use-archive-item.ts index d7096ea8..b08c91b6 100644 --- a/web/src/use/use-archive-item.ts +++ b/web/src/use/use-archive-item.ts @@ -3,16 +3,16 @@ import { isNil } from "lodash" import { type Policy } from "@/api/base-api" import archiveItemsApi, { - type ArchiveItemShowView, + type ArchiveItemAsShow, type ArchiveItemWhereOptions, type ArchiveItemFiltersOptions, } from "@/api/archive-items-api" -export { type ArchiveItemShowView, type ArchiveItemWhereOptions, type ArchiveItemFiltersOptions } +export { type ArchiveItemAsShow, type ArchiveItemWhereOptions, type ArchiveItemFiltersOptions } export function useArchiveItem(id: Ref) { const state = reactive<{ - archiveItem: ArchiveItemShowView | null + archiveItem: ArchiveItemAsShow | null policy: Policy | null isLoading: boolean isErrored: boolean @@ -23,7 +23,7 @@ export function useArchiveItem(id: Ref) { isErrored: false, }) - async function fetch(): Promise { + async function fetch(): Promise { const staticId = unref(id) if (isNil(staticId)) { throw new Error("id is required") @@ -45,7 +45,7 @@ export function useArchiveItem(id: Ref) { } } - async function save(): Promise { + async function save(): Promise { const staticId = unref(id) if (isNil(staticId)) { throw new Error("id is required") diff --git a/web/src/use/utils/use-authenticated-download.ts b/web/src/use/utils/use-authenticated-download.ts new file mode 100644 index 00000000..c68f57a2 --- /dev/null +++ b/web/src/use/utils/use-authenticated-download.ts @@ -0,0 +1,51 @@ +import { ref, nextTick, type Ref } from "vue" +import { useAuth0 } from "@auth0/auth0-vue" + +export function useAuthenticatedDownload(actionUrl: Ref) { + const { getAccessTokenSilently } = useAuth0() + + const isLoading = ref(false) + + async function submit() { + isLoading.value = true + try { + const accessToken = await getAccessTokenSilently() + + // Create form element + const form = document.createElement("form") + form.action = actionUrl.value + form.method = "post" + form.target = "_blank" + + // Create hidden input + const input = document.createElement("input") + input.type = "hidden" + input.name = "HOISTABLE_AUTHORIZATION_TOKEN" + input.value = accessToken + + // Append input to form and form to body + form.appendChild(input) + document.body.appendChild(form) + + // Submit form + form.submit() + + await nextTick() + + // Clean up + document.body.removeChild(form) + } catch (error) { + console.error(`Error fetching new access token: ${error}`, { error }) + throw error + } finally { + isLoading.value = false + } + } + + return { + submit, + isLoading, + } +} + +export default useAuthenticatedDownload diff --git a/web/src/use/utils/use-vuetify-color-classes-as-specific-color-classes.ts b/web/src/use/utils/use-vuetify-color-classes-as-specific-color-classes.ts new file mode 100644 index 00000000..152f2bc2 --- /dev/null +++ b/web/src/use/utils/use-vuetify-color-classes-as-specific-color-classes.ts @@ -0,0 +1,25 @@ +import { computed, Ref, toValue } from "vue" +import { isNil, isEmpty } from "lodash" + +/** + * Returns a computed ref that returns a specific color class based on the provided color classes. + * Given + * "red" could be used produce "text-red" or "bg-red" or "border-red" + */ +export function useVuetifyColorClassesAsSpecificColorClasses( + colorClasses: Ref, + prefix: string = "text" +) { + const textColorClass = computed(() => { + const colorClassesValues = toValue(colorClasses) + if (isNil(colorClassesValues)) return "" + if (isEmpty(colorClassesValues)) return "" + + const normalizedClass = colorClassesValues.split(" ").join("-") + return `${prefix}-${normalizedClass}` + }) + + return textColorClass +} + +export default useVuetifyColorClassesAsSpecificColorClasses diff --git a/web/src/utils/blocked-to-true-confirm.ts b/web/src/utils/blocked-to-true-confirm.ts new file mode 100644 index 00000000..2c07b074 --- /dev/null +++ b/web/src/utils/blocked-to-true-confirm.ts @@ -0,0 +1,19 @@ +/** + * Simple wrapper around window.confirm that returns true if the + * confirm dialog is blocked. + */ +export function blockedToTrueConfirm(message: string) { + const startTime = Date.now() + const result = window.confirm(message) + const endTime = Date.now() + + // If the confirm returns faster than a human could possibly click + // (e.g., in 1 millisecond), assume it's been blocked + if (endTime - startTime < 1) { + return true + } + + return result +} + +export default blockedToTrueConfirm diff --git a/web/src/utils/validators/email.ts b/web/src/utils/validators/email.ts new file mode 100644 index 00000000..650a5f6e --- /dev/null +++ b/web/src/utils/validators/email.ts @@ -0,0 +1,10 @@ +import { isNil, isEmpty } from "lodash" + +export function email(value: string | null): boolean | string { + if (isNil(value) || isEmpty(value)) return true + if (/^[a-z.-]+@[a-z.-]+\.[a-z]+$/i.test(value)) return true + + return "Must be a valid e-mail." +} + +export default email diff --git a/web/src/utils/validators/index.ts b/web/src/utils/validators/index.ts index ee367727..d3ced217 100644 --- a/web/src/utils/validators/index.ts +++ b/web/src/utils/validators/index.ts @@ -1,2 +1,3 @@ +export { email } from "./email" export { minimum } from "./minimum" export { required } from "./required"