diff --git a/.agents/skills/app-implementation/SKILL.md b/.agents/skills/app-implementation/SKILL.md new file mode 100644 index 00000000..03bd7265 --- /dev/null +++ b/.agents/skills/app-implementation/SKILL.md @@ -0,0 +1,254 @@ +--- +name: app-implementation +description: Implement frontend/UI features (React components, hooks, routing) under development//app in Comify. Use when adding or extending user-facing features in the social app or another bounded context's app layer. +--- + +# App Implementation + +## Overview + +Guides the implementation of **UI features** for the Comify application — frontend-only feature implementation (React components, hooks, routing), user interface, interactions, local/global state, and domain data integration, under `development//app`. Code style (quotes, semicolons, brace style, import ordering) follows the repository root [`AGENTS.md`](../../../AGENTS.md); this skill documents the app-layer-specific architecture on top of it. + +> **Scope:** UI feature implementation only. Domain logic (use-cases, gateways, repositories) is handled by the `domain-implementation` skill. + +## When to Use + +Invoke this skill when implementing **user-facing features** — anything the user sees or interacts with in the browser, under `development//app`. + +### What's IN scope + +| Category | Examples | +|----------|----------| +| **Components** | Panels, forms, lists, modals, tabs, menus, loading/result containers | +| **Pages/Routes** | Timeline, explore, post details, create post, creator profile, account settings | +| **Hooks** | `usePost`, `useTogglePostRating`, `useExplorePosts`, `useEstablish` | +| **Domain data integration** | Calling domain use-case functions (e.g. `^/domain/post/getById`) via the shared `requester` | +| **State** | Local component state, `AppContext` (identity, app state), URL/route state | +| **Routing** | React Router routes composed per feature (`Routes.tsx`), `ProtectedRoute`, layouts | +| **Forms** | Submission via the shared `Form` component and `useForm` hook, native field validation | +| **Accessibility** | Relies on `@maskingtech/designsystem` primitives for accessible building blocks | + +### What's OUT of scope + +| Category | Handled by | +|----------|-----------| +| **Domain logic / use-cases** | `domain-implementation` skill | +| **Database operations** | Backend services (`@theshelf/database`) | +| **Authentication** | `@theshelf/authentication` (consumed via `ProtectedRoute` / `AppContext`) | +| **File uploads** | `@theshelf/files` | +| **Event publishing** | `@theshelf/events` | +| **Backend API routes** | Backend services | + +## Prerequisites + +Before implementing a UI feature, ensure: + +- The domain logic (use-case function, domain model/types) is already implemented under `development//domain` (see `domain-implementation` skill) +- The feature name and requirements are clear, and you know which bounded sub-feature it belongs to (see naming convention below) +- Design system components from `@maskingtech/designsystem` and helper hooks from `@maskingtech/react-toolkit` are identified for reuse + +## Workflow + +### 1. Feature Discovery + +1. **Read the feature specification** — understand the user story and acceptance criteria +2. **Check existing UI patterns** — browse `development//app/components` for a similar feature folder to mirror (e.g. `post.rating` for a toggle-style engagement, `creator.account` for forms) +3. **Identify domain dependencies** — find the use-case function(s) under `development//domain/` that the UI needs to call, and the domain model types they return +4. **Determine state needs** — most data is server state fetched on demand via `useLoadData`/`usePagination` from `@maskingtech/react-toolkit`; only identity/app-wide state goes through `AppContext` + +### 2. Feature Scaffolding + +Real app features live directly under `development//app/components`, named per bounded sub-feature using **dot notation** when a feature is a satellite of a larger one (e.g. `post` + `post.rating` + `post.comic` + `post.reaction`, or `creator` + `creator.account` + `creator.profile`): + +``` +development//app/components// +├── .tsx # Top-level route components (Main, Create, Details, Routes, ...) +├── Routes.tsx # React Router tree for this feature, exported as default +├── components/ +│ ├── .tsx # Sub-components used by the route components +│ ├── elements/ +│ │ └── .tsx # Small leaf/presentational components +│ └── hooks/ +│ └── use.ts # Hooks scoped to a single sub-component +├── hooks/ +│ └── use.ts # Feature-level hooks (data fetching, mutations, navigation) +└── index.ts # Barrel export (components/hooks used by other features + default Routes) +``` + +Real examples from `development/social/app/components`: + +- `post/` — `Create.tsx`, `Details.tsx`, `Explore.tsx`, `Following.tsx`, `Highlight.tsx`, `Main.tsx`, `Reactions.tsx`, `Recommended.tsx`, `Remove.tsx`, `Routes.tsx`, plus `components/` (`DetailsPanel.tsx`, `LargePanel.tsx`, `PanelGrid.tsx`, `PanelList.tsx`, `SmallPanel.tsx`, `elements/EngagementRow.tsx`) and `hooks/` (`usePost.ts`, `useCreatorPosts.ts`, `useExplorePosts.ts`, `useRemovePost.ts`, ...) +- `post.rating/` — `components/Engagement.tsx`, `components/elements/Icon.tsx`, `components/hooks/useEngagement.ts`, `hooks/useTogglePostRating.ts` +- `creator.account/` — `Edit.tsx`, `Main.tsx`, `Routes.tsx`, `components/FullNameForm.tsx`, `components/NicknameForm.tsx`, `components/hooks/useFullNameFormHandler.ts`, `hooks/useUpdateFullName.ts` + +There is **no `api/`, `types/`, or `validation/` folder** in the app layer. Data shapes are imported straight from the domain package (see below), and submissions are validated by domain use-cases plus native HTML form attributes (`required`, `limit`, ...). + +### 3. Path Aliases & Domain Integration + +The app layer never implements its own API client — it calls domain use-case functions directly, passing a shared `requester`: + +```typescript +// development/social/app/components/post/hooks/usePost.ts + +import { useLoadData } from '@maskingtech/react-toolkit'; +import { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; + +import { requester } from '@comify/common/security'; + +import getPost from '^/domain/post/getById'; + +export default function usePost() +{ + const { postId } = useParams(); + + const getData = useCallback(async () => + { + return postId !== undefined + ? getPost(requester, postId) + : undefined; + }, [postId]); + + return useLoadData(getData, [postId]); +} +``` + +Path aliases (configured in `development//app/tsconfig.json`): + +- `^/*` → the bounded context root (so `^/domain/post`, `^/domain/relation`, ...) +- `~/*` → the app folder itself (so `~/components/common`, `~/components/application`, ...) +- `@comify/common/*` → the shared `development/common` package (e.g. `@comify/common/security` for `requester`) + +Domain types (e.g. `Post`, `Relation`, `Creator`) are imported with `import { type X } from '^/domain/'` and reused as-is for component props — there is no separate app-level type layer. + + +### 4. Component Conventions + +Components use **anonymous default-exported functions** with an inline, readonly `Props` type — not named exports or `interface`: + +```typescript +// development/social/app/components/post.rating/components/Engagement.tsx + +import { ClickArea, Row } from '@maskingtech/designsystem'; + +import { CompactNumber } from '~/components/common'; + +import Icon from './elements/Icon'; +import useEngagement, { type EngageHandler } from './hooks/useEngagement'; + +type Props = { + readonly isEngaged: boolean; + readonly count: number; + readonly onClick: EngageHandler; +}; + +export default function ({ isEngaged, count, onClick }: Props) +{ + const [isRated, ratingCount, handleClick] = useEngagement(isEngaged, count, onClick); + + return + + + + + ; +} +``` + +Page/route components follow the same pattern but typically have no props and read from `useParams`/`useOutletContext` instead (e.g. `post/Details.tsx`, `creator.account/Edit.tsx`). + +### 5. Routing Conventions + +Each feature exports its route tree as a JSX `` fragment (not a React Router object config), composed bottom-up: + +```typescript +// development/social/app/components/post/Routes.tsx + +import { Route } from 'react-router-dom'; + +import reactionRoutes from '~/components/post.reaction'; + +import Create from './Create'; +import Details from './Details'; +import Highlight from './Highlight'; +import Main from './Main'; +import Remove from './Remove'; + +export default }> + } /> + }> + {reactionRoutes} + }> + + }> + }> + +; +``` + +These per-feature `Routes.tsx` are mounted under a path prefix in the top-level `development//app/components/Routes.tsx`, inside a `ProtectedRoute` + `ApplicationLayout` wrapper for authenticated areas, or a `GuestLayout`/`LegalLayout` for public ones. There is no router-level lazy loading or route-guard HOC beyond `ProtectedRoute`, which simply renders `` when `identity` is `undefined` in `AppContext`. + + +### 6. State Management + +- **Server/data state:** fetched on-demand via `@maskingtech/react-toolkit` hooks — `useLoadData` for single-resource loads, `usePagination` for infinite lists — wrapping a domain use-case call +- **Global app state:** `AppContext` (React Context) exposes `identity` (the current `Creator`), `setIdentity`, and a generic `appState` map; provided once by `AppContextProvider` in `App.tsx` +- **Local UI state:** plain `useState` inside small hooks colocated with the component that needs it (e.g. `post.rating/components/hooks/useEngagement.ts` for optimistic rating counts, `relation/components/hooks/useEstablish.ts` for a follow-button's pending state) +- **Forms:** the shared `Form` component (`~/components/common`) wraps `@maskingtech/designsystem`'s `Form` and the `useForm` hook from `@maskingtech/react-toolkit`, which tracks `dirty`/`submitting` state and calls `onSubmit(data: FormData)` + +No Zustand, Jotai, React Query, Tailwind, CSS modules, or styled-components are used anywhere in the app layer — styling is limited to `@maskingtech/designsystem` components plus two global stylesheets (`components/theme.css`, `components/main.css`). + +### 7. Barrel Exports + +Every feature folder has an `index.ts` that re-exports the pieces other features are allowed to consume, and — by convention — the feature's `Routes.tsx` as the default export: + +```typescript +// development/social/app/components/post.rating/index.ts + +export { default as Engagement } from './components/Engagement'; + +export { default as useToggle } from './hooks/useTogglePostRating'; +``` + +```typescript +// development/social/app/components/post/index.ts + +export { default as Creator } from './Creator'; +export { default as Explore } from './Explore'; +export { default as Following } from './Following'; +export { default as Recommended } from './Recommended'; + +export { default } from './Routes'; +``` + +Cross-feature imports always go through this barrel (`~/components/post.rating`), never by reaching into another feature's internal folders. + +### 8. Testing + +There are currently **no UI test files** under `development//app` or `testing/`. When adding tests for app code, follow the project-wide convention from `AGENTS.md` and mirror the structure used for domain tests: + +- Place tests under `testing//app/...`, mirroring the `development//app/...` path of the file under test +- Name test files `.spec.ts(x)`, structured `describe` → `it` → setup → act → assert +- Mock `@comify/common/security` (`requester`) and domain use-case functions with `vi.mock()` rather than hitting real Keycloak/MongoDB/SeaweedFS/Kafka +- Run tests with `npm run test` (Vitest) from the repo root + +## Checklist + +Before marking a UI feature complete, verify: + +- [ ] The feature folder follows the dot-notation convention and sits under `development//app/components` +- [ ] Data is fetched/mutated by calling domain use-case functions with `requester` from `@comify/common/security` — no bespoke `api/` client was created +- [ ] Domain types are imported from `^/domain/`, not redeclared +- [ ] Components use anonymous default exports with an inline `readonly` `Props` type +- [ ] New routes are exported as a `` JSX fragment from the feature's `Routes.tsx` and mounted in the parent `Routes.tsx` +- [ ] Reusable pieces are re-exported from the feature's `index.ts`; other features import only through that barrel +- [ ] Styling relies on `@maskingtech/designsystem`, not ad-hoc CSS/Tailwind/styled-components +- [ ] Code follows the formatting rules in `AGENTS.md` (single quotes, semicolons, Allman braces, import ordering, leading/trailing blank lines) + +## References + +- [Project AGENTS.md](../../../AGENTS.md) — Full project conventions and setup +- [`.agents/skills/domain-implementation/SKILL.md`](../domain-implementation/SKILL.md) — Implementing the domain use cases this skill's UI consumes +- [React Router documentation](https://reactrouter.com/) — Route composition patterns used in `Routes.tsx` +- [Vitest documentation](https://vitest.dev/) — Testing framework + diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000..88356423 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,48 @@ +--- +name: code-review +description: Review code changes for bugs, regressions, security issues, convention drift, and missing tests. Use when reviewing a pull request or diff in the Comify codebase. +--- + +# Code Review + +## Overview + +Reviews TypeScript changes across Comify's bounded contexts (`development/`, `testing/`, `deployment/`) for correctness, architectural integrity, and adherence to the conventions in the repository root [`AGENTS.md`](../../../AGENTS.md). For domain or UI code specifically, also cross-check against the `domain-implementation`, `domain-testing`, and `app-implementation` skills — this skill focuses on the cross-cutting checks that apply to every change. + +## When to Use + +Invoke this skill when asked to review a diff, pull request, or set of staged changes. + +## Review Checklist + +### TypeScript & formatting + +- [ ] TypeScript strict mode compliance — no unjustified `any` (prefer `unknown` with narrowing); no new `as` assertions where a type guard would do +- [ ] Single quotes, semicolons on every statement, Allman brace style (see `AGENTS.md` → Code Style) +- [ ] One leading blank line at the top of the file, one blank line after the import block, one trailing blank line at EOF +- [ ] Imports ordered built-in → external → internal (`^/*`) → parent (`~/*`) → relative, alphabetized case-insensitively, no duplicates +- [ ] Inline type imports (`import { type Foo } from 'bar'`) rather than a separate `import type` statement +- [ ] No unused variables/imports, no stray `console.*` calls (use the shared `logger`) + +### Architecture + +- [ ] Bounded context boundaries are respected — no direct imports between `social`/`insights`/`moderation` domains; cross-context communication goes through `development/common/domain//` event contracts +- [ ] Domain logic doesn't leak into the app layer, and UI concerns don't leak into domain use cases +- [ ] New/changed domain use cases are registered with the correct `access` level in the relevant `deployment/segments/.*.json` file +- [ ] Side effects (DB, file store, event publishing) are isolated in small, named helper functions rather than inlined +- [ ] State-changing use cases that publish an event `await` the publish and roll back on failure instead of firing-and-forgetting + +### Tests + +- [ ] New/changed public use cases have a matching `testing//domain//.spec.ts` +- [ ] Domain tests use real `database`/`eventBroker`/`fileStore` integrations and fixture seed helpers — no `vi.mock()` +- [ ] App/UI tests (if any) mock the domain layer and `requester` with `vi.mock()` rather than hitting real integrations +- [ ] Happy path, error, and ownership/authorization cases are covered where applicable +- [ ] `npm run test` and `npm run lint` pass for every affected workspace + +## References + +- [Project AGENTS.md](../../../AGENTS.md) — Full project conventions and setup +- [`.agents/skills/domain-implementation/SKILL.md`](../domain-implementation/SKILL.md) +- [`.agents/skills/domain-testing/SKILL.md`](../domain-testing/SKILL.md) +- [`.agents/skills/app-implementation/SKILL.md`](../app-implementation/SKILL.md) diff --git a/.agents/skills/domain-implementation/SKILL.md b/.agents/skills/domain-implementation/SKILL.md new file mode 100644 index 00000000..e6dd349e --- /dev/null +++ b/.agents/skills/domain-implementation/SKILL.md @@ -0,0 +1,398 @@ +--- +name: domain-implementation +description: Implement domain use cases (business logic, records/models, segment registration) within a Comify bounded context. Use when adding or extending backend domain features in social, insights, or moderation. +--- + +# Domain Implementation + +## Overview + +Guides the implementation of new domain features within a bounded context in the Comify project — from domain analysis through to tested, production-ready code following project conventions. Code style (quotes, semicolons, brace style, import ordering) follows the repository root [`AGENTS.md`](../../../AGENTS.md); this skill documents the domain-layer-specific architecture on top of it. It is the implementation counterpart to the `domain-testing` skill. + +## When to Use + +Invoke this skill when: +- A new feature needs to be added to an existing bounded context (e.g., `social`, `insights`, `moderation`) +- A new bounded context needs to be scaffolded and populated with initial domain logic +- An existing feature requires extension with new domain rules or use cases + +## Prerequisites + +Before starting implementation, ensure you have: + +1. **Clear requirement** — What the feature should do, its boundaries, and success criteria +2. **Domain knowledge** — Understanding of the relevant bounded context and its existing models +3. **Dependency awareness** — Knowledge of which shared services (Keycloak, MongoDB, SeaweedFS, Kafka) the feature interacts with + +## Implementation Workflow + +### Step 1: Domain Analysis + +1. **Identify the bounded context** — Determine which context the feature belongs to (`social`, `insights`, `moderation`, or a new one). +2. **Map domain entities** — Identify the core domain objects, their properties, and relationships. +3. **Define use cases** — Break down the feature into atomic use-case functions (e.g., `create`, `read`, `update`, `delete`, `toggleRating`). +4. **Identify external dependencies** — Note which shared services are required (authentication, database, file storage, event streaming). + +> **Rule:** Each use-case function should be pure, focused on a single responsibility, and independently testable. + +### Step 2: Domain Model Design + +1. **Define TypeScript types** for both the **Record** (database layer) and the **Domain Model** (business layer). +2. **Establish validation rules** via the shared `validator` singleton (`@comify/common/integrations/validation`, which wraps `@theshelf/validation`) or custom validators. +3. **Define error types** specific to the domain (use domain-specific error classes, not generic errors). +4. **Document invariants** — invariants that must hold true for the domain model. + +#### Record vs Domain Model + +The implementation maintains a clear separation between the **Record** (database/storage layer) and the **Domain Model** (business layer): + +- **Record**: Represents the raw database document. Use `string` for IDs (as stored in MongoDB), include tenant scoping, soft-delete flags, and foreign keys to related entities. +- **Domain Model**: Represents the business entity presented to callers. Use `Identifier` for IDs, resolve relationships into rich domain objects (`Relation`, `Comic`, `Comment`), and expose computed properties (`hasParent`, `isRated`). + +```typescript +// Example: development/social/domain/post/definitions.ts +import { type Identifier } from '@comify/common/primitives/identifier'; + +import { type Comic } from '~/post.comic'; +import { type Comment } from '~/post.comment'; +import { type Metrics } from '~/post.metrics'; +import { type Relation } from '~/relation'; + +import { type BaseRecord } from '../definitions'; + +export type Record = BaseRecord & { + readonly tenantId: string; + readonly id: string; + readonly creatorId: string; + readonly comicId?: string; + readonly commentId?: string; + readonly parentId?: string; + readonly createdAt: string; + readonly deleted: boolean; +}; + +export type Post = { + readonly id: Identifier; + readonly parentId?: Identifier; + readonly createdAt: Date; + readonly creator: Relation; + readonly comic?: Comic; + readonly comment?: Comment; + readonly hasParent: boolean; + readonly metrics: Metrics; + readonly isRated: boolean; +}; + +export const RECORD_TYPE = 'post'; +``` + +#### Key Conventions + +- **Record IDs** are `string` (raw MongoDB `_id` values); **Domain Model IDs** are `Identifier` instances. +- **Relationships** in the Record are foreign key strings (`creatorId`, `comicId`, `parentId`); in the Domain Model they are resolved objects (`creator: Relation`, `comic?: Comic`, `comment?: Comment`). +- **Computed properties** (`hasParent`, `isRated`) are derived from the data and do not exist in the Record. +- **`RECORD_TYPE`** constant is exported for use by database queries (`database.searchRecords(RECORD_TYPE, ...)`). +- **Soft-delete** is indicated by a `deleted: boolean` flag in the Record. +- **Tenant scoping** is always present via `tenantId` for multi-tenant isolation. + +### Step 3: Use-Case Implementation + +Implement each use-case function following these conventions: + +1. **File location:** `development//domain///index.ts` — each use case gets its own **directory** with an `index.ts` as the public entry point. A use case may be a single file instead of a directory when it has no helpers of its own (e.g. `development/social/domain/post/getById/index.ts`). +2. **Naming (public use cases):** camelCase directory names (`establish`, `explore`, `get`, `getById`, `getByCreator`, `getByFollowing`, `getByParent`, `getFollowers`, `getFollowing`, `getMany`, `getManyById`, `getRecommended`, `createWithComic`, `createWithComment`, `remove`). +3. **Naming (internal helpers):** `_camelCase` subdirectories for private/internal helpers (`_create`, `_retrieveById`, `_toModel`, `_toModels`, `_translateMany`, `_retrieveFollowing`). These are not use cases themselves but are shared building blocks consumed by one or more public use cases (e.g. `post/_create` is the shared creation logic used by both `post/createWithComic` and `post/createWithComment`). +4. **Pure functions** where possible; side effects (DB, external APIs) should be explicit and isolated. +5. **Error handling:** Domain-specific error classes extending `@comify/common/integrations/errors` types (e.g., `BadRequest`, `NotFound`). +6. **Type imports** inline: `import { type Post } from '../definitions'` +7. Follow the formatting rules in `AGENTS.md` (single quotes, semicolons, Allman braces, import ordering, leading/trailing blank lines). + +```typescript +// Example: development/social/domain/relation/establish/index.ts +import { type Requester } from '@comify/common/security'; + +import { type RelationKey } from '../definitions'; +import { logger } from '../integrations'; + +import create from './create'; +import exists from './exists'; +import publish from './publish'; +import RelationAlreadyExists from './RelationAlreadyExists'; +import remove from './remove'; + +export default async function (requester: Requester, followingId: string): Promise +{ + const key: RelationKey = { followerId: requester.principalId, followingId }; + + const relationExists = await exists(key); + + if (relationExists) + { + throw new RelationAlreadyExists(); + } + + await create(key); + + try + { + await publish(requester, key); + } + catch (error) + { + logger.error('Failed to establish relation', error); + + await remove(key); + + throw error; + } +} + +export { RelationAlreadyExists }; +``` + +#### Record vs Model Separation + +The actual implementation maintains a clear separation between the **Record** (database/storage layer) and the **Model** (domain layer): + +- **Record**: `{ id: string | undefined, followerId: string, followingId: string }` — represents the raw database document. The `id` is `undefined` for unestablished relations. +- **Model**: `{ following: Creator, established: boolean, self: boolean }` — represents the domain object presented to callers. + +Conversion functions (`_toModel`, `_toModels`) bridge the two layers, fetching related entities (e.g., `Creator`) and computing derived properties (`established`, `self`). + +```typescript +// Example: development/social/domain/relation/_toModel/index.ts +import { type TenantId } from '@comify/common/domain/tenant'; + +import getCreator from '~/creator/getById'; + +import { type Record, type Relation } from '../definitions'; + +import createModel from './createModel'; + +export default async function (tenantId: TenantId, record: Record): Promise +{ + const creator = await getCreator(tenantId, record.followingId); + + return createModel(record, creator); +} +``` + +#### Database Integration + +Instead of a repository interface, the actual implementation uses the `database` module from `@comify/common/integrations/database` with methods like `readRecord` and `searchRecords`. Queries use a query statement format: + +```typescript +// Example: database query +import database from '@comify/common/integrations/database'; + +const result = await database.searchRecords(RECORD_TYPE, { + followerId: { EQUALS: followerId } +}, undefined, undefined, limit, offset); + +return result.records; +``` + +### Step 4: Exposing Use Cases (Jitar Segments) + +There is no separate REST API layer / route file to write. Jitar directly exposes the exported domain functions as remotely callable procedures; the frontend (or another bounded context) calls them like regular async functions (e.g. `import remove from '^/domain/post/remove'`, then `await remove(requester, id)`). What you need to do instead is **register the new use case(s) in the segment configuration** so Jitar knows how (and whether) to expose them: + +1. **File location:** `deployment/segments/..json` — segments group functions by access pattern, e.g. `social.bff.json` (functions callable from the browser/BFF), `social.reads.json` / `social.writes.json` (internal read/write helpers callable between segments), `social.errors.json` (error classes that must be serializable across segment boundaries). +2. **Access levels:** `"public"` (callable directly from the client), `"protected"` (callable between segments/services but not from the client), `"private"` (not implemented here, reserved for segment-local only). +3. **Client-facing use cases** (a top-level `index.ts` that clients call, e.g. `./social/domain/post/createWithComic`) are registered as `"access": "public"` in the relevant `*.bff.json` segment. **Top-level use cases that only server-side code calls** (e.g. `./social/domain/creator/create`) must not be registered as `"public"` in any `*.bff.json` segment. +4. **Internal helpers** that perform the actual DB/file reads or writes (e.g. `./social/domain/post/_create/persist`, `./social/domain/post/explore/retrieve`) are registered as `"access": "protected"` in the relevant `*.reads.json` / `*.writes.json` segment so they can run in a separate (e.g. database-adjacent) deployment segment. +5. **Domain-specific error classes** that can be thrown back across a segment boundary (e.g. `PostNotFound`, `InvalidPost`) must be registered in the context's `*.errors.json` segment so Jitar can (de)serialize them correctly. +6. **Authentication, validation, error mapping** all happen inside the domain layer itself (see Step 3) — there is no separate middleware chain per route. Jitar services apply cross-cutting middleware (origin, authentication, tenancy) globally, configured once per service in `deployment/services/**/*.json`. + +```json +// Example additions to deployment/segments/social.bff.json +"./social/domain/post/createWithComic": { "default": { "access": "public" } }, +"./social/domain/post/getById": { "default": { "access": "public" } } + +// Example additions to deployment/segments/social.reads.json +"./social/domain/post/_retrieveById": { "default": { "access": "protected" } } + +// Example additions to deployment/segments/social.writes.json +"./social/domain/post/_create/persist": { "default": { "access": "protected" } } + +// Example addition to deployment/segments/social.errors.json +"./social/domain/post/_retrieveById/PostNotFound": { "default": {} } +``` + +### Step 5: Testing + +Tests are **integration tests**, not isolated unit tests with mocked collaborators. They run against real `database`, `eventBroker`, and `fileStore` integrations (backed by the Docker services from `AGENTS.md`), connecting once per file and reseeding fixture data before each test. Nothing is mocked with `vi.mock()` in this codebase's domain tests. + +1. **File location:** `testing//domain//.spec.ts` (mirrors `development//domain//`), e.g. `testing/social/domain/post/createWithComic.spec.ts`. +2. **Test naming:** a single top-level `describe('index', () => { it('should ', async () => { ... }) })` per public use case being tested — the `describe` name is `'index'`, not the use-case name. +3. **Connect/disconnect real integrations** in `beforeAll` / `afterAll`, only for the integrations the use case actually touches (`database`, `eventBroker`, `fileStore`): + +```typescript +beforeAll(async () => +{ + await Promise.all([ + database.connect(), + eventBroker.connect() + ]); +}); + +afterAll(async () => +{ + await Promise.all([ + database.disconnect(), + eventBroker.disconnect() + ]); +}); +``` + +4. **Seed fixture data** in `beforeEach` using the shared seed helpers exported from `testing//fixtures` (e.g. `seedPosts`, `fullySeedPosts`, `fullySeedCreators`). Fixture records and pre-built `Requester`s (`REQUESTERS.ALICE`, etc.) live in `testing//fixtures/data/*.fixture.ts`. +5. **Assert against real side effects** — read the record back via `database.readRecord`/`searchRecords`, or assert a rejected promise against a domain error class (`expect(promise).rejects.toThrow(PostNotFound)` or `.rejects.toStrictEqual(new RelationAlreadyExists())`). +6. **Test categories:** + - **Happy path** — Valid input, record persisted/returned as expected + - **Error cases** — Domain-specific errors thrown (e.g. `PostNotFound`, `RelationAlreadyExists`) + - **Authorization/ownership cases** — e.g. a requester who doesn't own a resource cannot modify it +7. **Coverage target:** Aim for high line and branch coverage on use-case logic (`npm run test-coverage`). + +```typescript +// Example: testing/social/domain/post/remove.spec.ts +import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import database from '@comify/common/integrations/database'; +import eventBroker from '@comify/common/integrations/events'; + +import { PostNotFound, RECORD_TYPE, type Record } from '@comify/social/domain/post'; +import remove from '@comify/social/domain/post/remove'; + +import { REQUESTERS, POST_RECORDS, seedPosts } from '../../fixtures'; + +beforeAll(async () => +{ + await Promise.all([ + database.connect(), + eventBroker.connect() + ]); +}); + +afterAll(async () => +{ + await Promise.all([ + database.disconnect(), + eventBroker.disconnect() + ]); +}); + +beforeEach(async () => +{ + await seedPosts(); +}); + +describe('index', () => +{ + it('should soft delete a post', async () => + { + await remove(REQUESTERS.ALICE, POST_RECORDS.SECOND.id); + + const result = await database.readRecord(RECORD_TYPE, { id: { EQUALS: POST_RECORDS.SECOND.id } }); + + expect(result.record?.deleted).toBeTruthy(); + }); + + it('should not delete a post from another creator', async () => + { + await remove(REQUESTERS.BOB, POST_RECORDS.FIRST.id); + + const result = await database.readRecord(RECORD_TYPE, { id: { EQUALS: POST_RECORDS.FIRST.id } }); + + expect(result.record?.deleted).toBeFalsy(); + }); +}); +``` + +### Step 6: Event Publishing (If Applicable) + +Shared cross-context event contracts (topic, event name, payload shape, `publish`/`subscribe` functions) live in `development/common/domain///` and wrap the `eventBroker` integration (`@comify/common/integrations/events`, itself backed by `@theshelf/events` with a Kafka or in-memory driver). The social (or other context) domain layer only ever imports and calls the already-defined `publish` function for that event — it does not call `eventBroker` directly, and it does not define new event infrastructure per feature. + +1. **Shared event module location:** `development/common/domain///{definitions,index,publish,subscribe}.ts`, e.g. `development/common/domain/post/added`. `definitions.ts` holds `EVENT_NAME`; the entity-level `../definitions.ts` holds the shared `EVENT_TOPIC` and `EventData` shape for that entity. +2. **Calling from the domain layer:** a dedicated `publish.ts` next to the use-case imports the shared `publish` function (aliased), fills in `contextId` (the bounded context's `CONTEXT_ID`), `principalId`, `tenantId`, and the entity-specific fields, and is invoked **after** the state-changing persistence step — it is awaited, not fire-and-forget. +3. **Event naming:** past-tense, domain-language (`added`, `removed`, `established`, `unestablished`), scoped under an entity topic (`common.post`, `common.relation`). +4. **Compensating action on failure** — if publishing fails, the use case logs the error, **rolls back** the just-applied state change (e.g. delete the just-created record, or flip `deleted` back to `false`), and re-throws the error. This is the opposite of "fire-and-forget": the call is awaited and its failure is treated as the whole use case failing. + +```typescript +// Example: development/social/domain/post/_create/publish.ts +import { publish as publishPostAdded } from '@comify/common/domain/post/added'; +import { type TenantId } from '@comify/common/domain/tenant'; +import { type Identifier } from '@comify/common/primitives/identifier'; + +import { CONTEXT_ID } from '~/definitions'; + +export default async function (tenantId: TenantId, principalId: Identifier, postId: Identifier, parentId?: Identifier): Promise +{ + return publishPostAdded({ + contextId: CONTEXT_ID, + principalId, + tenantId, + postId, + parentId + }); +} +``` + +```typescript +// Example: development/social/domain/post/_create/index.ts — awaited publish with compensating rollback +export default async function (requester: Requester, data: CreateData): Promise +{ + validate(data); + + const record = createRecord(requester.tenantId, data.creatorId, data.comicId, data.commentId, data.parentId); + + const postId = await persist(record); + + try + { + await publish(requester.tenantId, requester.principalId, record.id, record.parentId); + + return postId; + } + catch (error) + { + logger.error('Failed to create post', error); + + await remove(postId); + + throw error; + } +} +``` + +## Checklist + +Before marking the feature as complete, verify: + +- [ ] Domain models are well-typed with full TypeScript strict mode compliance, with a clear `Record` (storage) vs. domain model (e.g. `Post`, `Relation`) split +- [ ] All use-case functions make side effects (DB, file store, event publishing) explicit via small, composed helper functions (`_create`, `_retrieveById`, `persist`, `publish`, etc.) +- [ ] Domain-specific error types extend `@comify/common/integrations/errors` (`BadRequest`, `NotFound`, `ValidationError`) instead of generic errors +- [ ] New public use cases (and any protected helpers/errors they need) are registered in the relevant `deployment/segments/.*.json` files with the correct `access` level +- [ ] Tests are added under `testing//domain//.spec.ts`, using real `database`/`eventBroker`/`fileStore` connections and fixture seed helpers, covering happy path, domain-error cases, and ownership/authorization cases +- [ ] State-changing use cases that publish an event await the publish and roll back (compensate) on failure rather than firing-and-forgetting +- [ ] A `RECORD_TYPE` constant is exported alongside each Record type +- [ ] Code follows the formatting rules in `AGENTS.md` (single quotes, semicolons, Allman braces, inline type imports, import ordering) +- [ ] `npm run test` (and `npm run lint`) pass for the affected workspace + +## Common Pitfalls + +1. **Hidden side effects** — Keep DB calls, file store calls, and event publishing in their own small named functions (`persist`, `publish`, `retrieve`) composed by the use case's `index.ts`, rather than inlined ad hoc. +2. **Generic error handling** — Use domain-specific error classes (extending `BadRequest`/`NotFound`/`ValidationError`) for clarity and correct segment (de)serialization. +3. **Tight coupling between contexts** — Respect bounded context boundaries; cross-context communication goes through the shared `development/common/domain//` event contracts, not direct imports between `social`/`insights`/`moderation` domains. +4. **Forgetting segment registration** — A new public use case or a newly extracted protected helper that isn't added to the right `deployment/segments/*.json` file won't be callable (or won't run in the intended segment) even though the code compiles and local tests pass. +5. **Treating event publishing as fire-and-forget** — In this codebase, failed event publishing after a persisted change must trigger a compensating rollback and a re-thrown error, not a silently swallowed failure. +6. **Mocking in domain tests** — Domain `.spec.ts` tests connect to real `database`/`eventBroker`/`fileStore` integrations and reseed fixtures; don't introduce `vi.mock()` for these, follow the existing integration-test pattern instead. +7. **Ignoring formatting rules** — Don't drop semicolons or switch to double quotes; let ESLint (`npm run lint`) enforce the rules in `AGENTS.md` rather than manually overriding them. + +## References + +- [Project AGENTS.md](../../../AGENTS.md) — Full project conventions and setup +- [testing/social/domain-tests.md](../../../testing/social/domain-tests.md) — Documented behavior of existing social domain tests +- [@theshelf/validation](https://www.npmjs.com/package/@theshelf/validation) — Validation library (wrapped by `@comify/common/integrations/validation`) +- [@theshelf/events](https://www.npmjs.com/package/@theshelf/events) — Event publishing via Kafka/in-memory driver (wrapped by `@comify/common/integrations/events`) +- [@theshelf/authentication](https://www.npmjs.com/package/@theshelf/authentication) — Keycloak/OIDC authentication +- [Jitar](https://jitar.dev) — Distributed runtime responsible for exposing domain functions as remote procedures via segment configuration +- [Vitest documentation](https://vitest.dev/) — Testing framework diff --git a/.agents/skills/domain-testing/SKILL.md b/.agents/skills/domain-testing/SKILL.md new file mode 100644 index 00000000..55fd9e7c --- /dev/null +++ b/.agents/skills/domain-testing/SKILL.md @@ -0,0 +1,385 @@ +--- +name: domain-testing +description: Write integration tests for domain use cases (testing//domain/**/*.spec.ts) in a Comify bounded context. Use when adding, extending, or improving coverage for domain-layer tests. +--- + +# Domain Testing + +## Overview + +Guides writing **integration tests, not isolated unit tests**, for domain use cases within a bounded context in the Comify project (`social`, `insights`, `moderation`, etc.). Tests run against real `database`, `eventBroker`, `fileStore` and `http` integrations (backed by in-memory/mapped drivers in the test environment) and reseed fixture data before each test — nothing is mocked with `vi.mock()`. This is the testing counterpart to the `domain-implementation` skill (see [`../domain-implementation/SKILL.md`](../domain-implementation/SKILL.md)) and documents the exact conventions already used by the ~20 spec files under `testing/social/domain`. Formatting (quotes, semicolons, import ordering) follows the repository root [`AGENTS.md`](../../../AGENTS.md). + +## When to Use + +Invoke this skill when: +- A new public use case was added under `development//domain//` and needs a corresponding `.spec.ts` +- An existing use case gained new branches (validation rule, error case, ownership check) that need test coverage +- You need to add or extend fixture data to support a new test scenario +- You are asked to improve coverage on an existing domain feature + +> **Scope:** This skill covers `testing//domain/**/*.spec.ts` only. UI/component tests are out of scope (see `app-implementation`); it does not cover implementing the use case itself (see `domain-implementation`). + +## Prerequisites + +Before writing tests, ensure you have: + +1. **The use case already implemented** — its `index.ts`, exported error classes, and `RECORD_TYPE`/`Record` type are in place under `development//domain/`. +2. **Knowledge of which integrations it touches** — look at the use case's imports: does it call `database` (directly or via `~/integrations`), `eventBroker` (via a `publish.ts` helper), or `fileStore`/`http` (via image creation/download)? +3. **Familiarity with the available fixtures** — `testing//fixtures` (shared across the context) and, if present, a feature-local `testing//domain//fixtures` folder for data the shared fixtures don't cover. + +## Test File Anatomy + +### File location & naming + +- **Location:** `testing//domain//.spec.ts` — mirrors `development//domain//`, but as a **flat file**, not a directory (e.g. `development/social/domain/post/remove/index.ts` → `testing/social/domain/post/remove.spec.ts`). +- **One spec file per public use case.** Internal helpers (`_create`, `_toModel`, etc.) are exercised indirectly through the public use case's tests, except where a helper is directly imported and tested in isolation (e.g. `testing/social/domain/notification/create.spec.ts` imports `@comify/social/domain/notification/_create` directly because that helper has no separate public wrapper used standalone). +- **Describe block:** always a single top-level `describe('index', () => { ... })` — the literal string `'index'`, never the use-case name: + +```typescript +describe('index', () => +{ + it('should create a post', async () => + { + // ... + }); +}); +``` + +- **Imports are grouped and ordered** exactly like production code: external (`vitest`) → `@theshelf/*`/`@comify/*` (alphabetized, blank line between distinct groups) → the use case + its domain types → relative fixtures import last. A blank line (empty first line) opens every file, matching the rest of the codebase. + +```typescript +// Pattern observed across every spec file +import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import database from '@comify/common/integrations/database'; +import eventBroker from '@comify/common/integrations/events'; + +import { PostNotFound, RECORD_TYPE, type Record } from '@comify/social/domain/post'; +import remove from '@comify/social/domain/post/remove'; + +import { REQUESTERS, POST_RECORDS, seedPosts } from '../../fixtures'; +``` + +### Connecting real integrations + +Connect **only** the integrations the use case under test actually exercises, in `beforeAll`, and tear them down in `afterAll`. Decide which ones to include by tracing the use case's dependencies: + +| Use case touches... | Connect | +|---|---| +| Reads/writes records (almost always) | `database` | +| Publishes a domain event (`publish.ts` helper) | `eventBroker` | +| Creates/downloads/reads images | `fileStore` | +| Downloads from an external URL | `fileStore` (the mapped `http` driver is seeded via fixtures, not connected explicitly) | + +```typescript +beforeAll(async () => +{ + await Promise.all([ + database.connect(), + eventBroker.connect() + ]); +}); + +afterAll(async () => +{ + await Promise.all([ + database.disconnect(), + eventBroker.disconnect() + ]); +}); +``` + +When only one integration is needed, skip `Promise.all` entirely: + +```typescript +beforeAll(async () => +{ + await database.connect(); +}); + +afterAll(async () => +{ + await database.disconnect(); +}); +``` + +### Seeding fixture data + +Reseed fixture data in `beforeEach` using the helpers exported from `testing//fixtures` (barrel of `data/` + `setup/`). Prefer the **narrowest** seed helper(s) that satisfy the use case's preconditions: + +| Helper | Seeds | +|---|---| +| `seedCreators()`, `seedPosts()`, `seedRatings()`, `seedRelations()`, `seedNotifications()`, `seedComics()`, `seedComments()`, `seedCreatorMetrics()`, `seedPostMetrics()`, `seedImages()` | A single record type (granular) | +| `fullySeedCreators()` | Creators + Creator Metrics + Relations | +| `fullySeedPosts()` | `fullySeedCreators()` + Images + Comics + Comments + Posts + Post Metrics + Ratings | +| `fullySeedNotifications()` | `fullySeedPosts()` + Notifications | + +```typescript +beforeEach(async () => +{ + await seedPosts(); +}); + +// or combine multiple independent seeds: +beforeEach(async () => +{ + await Promise.all([ + seedPosts(), + seedRatings() + ]); +}); +``` + +Some use cases need **no** seeding at all (stateless creation from arbitrary input, e.g. `post.comic/create.spec.ts`, `post.comment/create.spec.ts`) — in that case omit `beforeEach` entirely. + +### Feature-local fixtures + +When the shared `testing//fixtures` data isn't enough (e.g. malformed/edge-case input values, or mocked HTTP responses specific to one feature), add a **feature-local fixtures folder** next to the spec files, mirroring `testing/social/domain/image/fixtures/`: + +``` +testing/social/domain/image/fixtures/ +├── data.fixture.ts # URLs / data URLs / mock Response objects specific to image tests +├── setup.fixture.ts # seedImages(): configures the mapped http driver +└── index.ts # export * from './data.fixture'; export * from './setup.fixture'; +``` + +```typescript +// testing/social/domain/image/fixtures/setup.fixture.ts +import { HttpMethods } from '@theshelf/http'; +import type { MappedDriver } from '@theshelf/http'; + +import { driver } from '@comify/common/integrations/http'; + +import { URLS, RESPONSES } from './data.fixture'; + +export function seedImages(): void +{ + const mappedDriver = driver as MappedDriver; + + mappedDriver.clear(); + + mappedDriver.setMapping(HttpMethods.HEAD, URLS.VALID.toString(), RESPONSES.VALID); + mappedDriver.setMapping(HttpMethods.GET, URLS.VALID.toString(), RESPONSES.VALID); + mappedDriver.setMapping(HttpMethods.HEAD, URLS.INVALID_TYPE.toString(), RESPONSES.INVALID_TYPE); + mappedDriver.setMapping(HttpMethods.HEAD, URLS.INVALID_SIZE.toString(), RESPONSES.INVALID_SIZE); + mappedDriver.setMapping(HttpMethods.HEAD, URLS.NONEXISTING.toString(), RESPONSES.NONEXISTING); +} +``` + +Import feature-local fixtures with a relative `./fixtures` path, and shared fixtures with `../../fixtures`, never mixing the two re-export surfaces. + +### Shared fixture data conventions (when extending `testing//fixtures`) + +If a new feature needs fixture **records** that other tests could also reuse (not one-off edge-case values), extend the shared fixtures instead of duplicating data locally: + +1. Add/extend a `testing//fixtures/data/.fixture.ts` exporting a `Record` keyed by scenario name (e.g. `ALICE`, `FIRST`, `BOB_ALICE`), re-exported from `testing//fixtures/data/index.ts`. +2. Add a `seed()` function to `testing//fixtures/setup/database.fixture.ts` using the shared `seed(type, records)` helper (which deletes all existing records of that type, then recreates them from `Object.values(...)`), and re-export it from `testing//fixtures/setup/index.ts`. +3. If the new entity should be part of an existing composite helper (`fullySeedPosts`, etc.), add it there too — composites always run their sub-seeds via `Promise.all`. +4. If you need a `Requester`, don't hand-roll one — `REQUESTERS` is derived automatically from `CREATOR_RECORDS` keys (`{ principalId: record.id, tenantId: record.tenantId }`), so adding a creator fixture automatically gives you `REQUESTERS.`. + +## Writing Assertions + +### Happy path — assert the real side effect, not the return value alone + +Read the record back from `database` (or the file from `fileStore`) rather than trusting only the use case's return value: + +```typescript +it('should create a post', async () => +{ + const id = await createWithComic(REQUESTERS.ALICE, { imageDataUrl: IMAGE_DATA_URLS.COMIC }); + + const result = await database.readRecord(RECORD_TYPE, { id: { EQUALS: id } }); + + expect(result.record).toBeDefined(); + expect(result.record?.creatorId).toBe(REQUESTERS.ALICE.principalId); + expect(result.record?.comicId).toBeDefined(); + expect(result.record?.createdAt).toBeDefined(); +}); +``` + +For list/query use cases, assert both length and ordering explicitly (most domain lists are sorted by recency): + +```typescript +it('should get posts from everyone followed by the requester', async () => +{ + const posts = await getByFollowing(REQUESTERS.CHARLIE, { offset: 0, limit: 7 }); + + expect(posts).toHaveLength(2); + expect(posts[0].id).toBe(POST_RECORDS.SECOND.id); + expect(posts[1].id).toBe(POST_RECORDS.FIRST.id); +}); +``` + +### Error cases — pick the right matcher + +- Use `.rejects.toThrow(ErrorClass)` when only the error **type** matters: + +```typescript +const promise = remove(REQUESTERS.HENRY, POST_RECORDS.DELETED.id); + +await expect(promise).rejects.toThrow(PostNotFound); +``` + +- Use `.rejects.toStrictEqual(new ErrorClass(...))` when the error carries **data** that must also match (e.g. validation message maps), or when you want an exact-instance check: + +```typescript +const messages = new Map([['mimeType', 'Invalid mime type']]); + +const promise = create('invalid_type', DATA_URLS.INVALID_TYPE); + +await expect(promise).rejects.toStrictEqual(new InvalidImage(messages)); +``` + +```typescript +const promise = establish(REQUESTERS.BOB, CREATOR_RECORDS.ALICE.id); + +await expect(promise).rejects.toStrictEqual(new RelationAlreadyExists()); +``` + +### Authorization / ownership cases — assert the *absence* of a side effect + +When a requester doesn't own the resource, the use case typically resolves silently (the mutation is skipped) rather than throwing — assert the record is **unchanged**: + +```typescript +it('should not delete a post from another creator', async () => +{ + await remove(REQUESTERS.BOB, POST_RECORDS.FIRST.id); + + const result = await database.readRecord(RECORD_TYPE, { id: { EQUALS: POST_RECORDS.FIRST.id } }); + + expect(result.record?.deleted).toBeFalsy(); +}); +``` + +## Test Categories Checklist (per use case) + +For every public use case being tested, cover as many of the following as apply: + +- [ ] **Happy path** — valid input produces the expected persisted/returned result +- [ ] **Validation errors** — invalid input (too long, malformed, wrong type/size) throws the domain-specific error +- [ ] **Not-found errors** — operating on a non-existent or already soft-deleted record throws the `*NotFound` error +- [ ] **Duplicate/conflict errors** — creating something that already exists throws the `*AlreadyExists` error +- [ ] **Ownership/authorization** — a requester who doesn't own the resource cannot mutate it (and no error is thrown unless the domain defines one) +- [ ] **Exclusion/filtering behavior** — e.g. recommended posts exclude the requester's own posts, following lists only include relevant tenants +- [ ] **Ordering/pagination** — list results come back in the documented order (usually most-recent-first) and respect `limit`/`offset` +- [ ] **Search/partial-match behavior** — for use cases that accept a search term, cover no-match, full-name match, nickname match, and partial/split-name match as applicable + +Not every use case needs every category — match the categories to the branches actually present in the implementation (see the use case's `index.ts` and its helper files for the branches to cover). + +## Worked Example + +This is a **fictional** illustration using a hypothetical `creator.badge` feature (not an existing path in the repo) to show how the pieces fit together — a use case `development/social/domain/creator.badge/award/index.ts` that awards a badge to a creator, owned by `requester`, and throws `BadgeAlreadyAwarded` on a duplicate: + +```typescript + +import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import database from '@comify/common/integrations/database'; +import eventBroker from '@comify/common/integrations/events'; + +import { RECORD_TYPE, type Record } from '@comify/social/domain/creator.badge'; +import award, { BadgeAlreadyAwarded } from '@comify/social/domain/creator.badge/award'; + +import { REQUESTERS, seedCreatorBadges } from '../../fixtures'; + +beforeAll(async () => +{ + await Promise.all([ + database.connect(), + eventBroker.connect() + ]); +}); + +afterAll(async () => +{ + await Promise.all([ + database.disconnect(), + eventBroker.disconnect() + ]); +}); + +beforeEach(async () => +{ + await seedCreatorBadges(); +}); + +describe('index', () => +{ + it('should award a badge to a creator', async () => + { + await award(REQUESTERS.ALICE, 'early-adopter'); + + const result = await database.readRecord(RECORD_TYPE, { + creatorId: { EQUALS: REQUESTERS.ALICE.principalId }, + badge: { EQUALS: 'early-adopter' } + }); + + expect(result.record).toBeDefined(); + }); + + it('should NOT award a duplicate badge', async () => + { + const promise = award(REQUESTERS.BOB, 'top-commenter'); + + await expect(promise).rejects.toStrictEqual(new BadgeAlreadyAwarded()); + }); +}); +``` + +> This is illustrative only — before writing a real test, always read the real `index.ts`, its helpers, and its exported error classes for the use case under test, and seed/assert against **its** actual signature and behavior rather than assuming the shape shown here. + +## Running & Validating Tests + +```bash +# Run the full test suite +npm run test + +# Run only social domain tests +npm run test -- testing/social/domain/ + +# Run a single feature's tests +npm run test -- testing/social/domain/post/ + +# Run a single spec file +npm run test -- testing/social/domain/post/remove.spec.ts + +# Check coverage +npm run test-coverage +``` + +Always run the affected spec file(s) after writing or changing a test, and run `npm run lint` on the touched workspace (`testing/`) to confirm import ordering, quote style, and formatting rules are satisfied. + +## Checklist + +Before marking test coverage complete, verify: + +- [ ] Spec file is placed at `testing//domain//.spec.ts`, mirroring the use case's directory under `development//domain//` +- [ ] A single `describe('index', () => { ... })` wraps all `it` blocks +- [ ] Only the integrations the use case actually touches are connected/disconnected (`database`, `eventBroker`, `fileStore`) +- [ ] Fixtures are reseeded in `beforeEach` using the narrowest applicable shared seed helper(s), or a feature-local `fixtures/` folder when shared data doesn't cover the scenario +- [ ] Happy path assertions read back real state via `database.readRecord`/`searchRecords` (or `fileStore.readFile`), not just the use case's return value +- [ ] Error cases use `.rejects.toThrow(ErrorClass)` for type-only checks or `.rejects.toStrictEqual(new ErrorClass(...))` when error data matters +- [ ] Ownership/authorization branches are covered by asserting the resource is unchanged for non-owners +- [ ] List-returning use cases assert both length and element ordering +- [ ] No `vi.mock()` is used for `database`/`eventBroker`/`fileStore`/`http` — only the mapped/memory drivers already wired up for the test environment +- [ ] Code follows the formatting rules in `AGENTS.md` (single quotes, semicolons, Allman braces, inline type imports, import ordering, blank line after imports) +- [ ] `npm run test` (and `npm run lint`) pass for the affected workspace + +## Common Pitfalls + +1. **Wrong `describe` name** — it must be `'index'`, not the use-case or feature name; this is a hard convention across every existing spec file. +2. **Over-seeding** — pulling in `fullySeedPosts()` when a narrower `seedCreators()`/`seedPosts()` would do makes tests slower and obscures what the test actually depends on. +3. **Connecting unused integrations** — only connect what the use case under test touches; e.g. a pure read from `database` doesn't need `eventBroker.connect()`. +4. **Trusting the return value only** — always read the record back (or query related records) to prove the side effect actually happened, especially for void-returning use cases like `remove`/`establish`. +5. **Using `toThrow` when the error carries data** — validation errors like `InvalidImage(messages)` need `toStrictEqual(new InvalidImage(messages))` to actually verify the message map, not just the error type. +6. **Mocking collaborators** — don't introduce `vi.mock()` for `database`/`eventBroker`/`fileStore`/`http`; use the existing fixture/seed pattern against the real (in-memory/mapped) drivers instead. +7. **Forgetting ordering assertions** — a list test that only checks `toHaveLength(n)` without checking element order/identity can silently hide a broken sort. +8. **Duplicating fixture data inline** — reuse `testing//fixtures` (or a feature-local `fixtures/` folder for truly feature-specific edge cases) instead of hardcoding new records inside the spec file. + +## References + +- [`.agents/skills/domain-implementation/SKILL.md`](../domain-implementation/SKILL.md) — Step 5 "Testing" section this skill expands on, plus the full use-case implementation conventions these tests exercise +- [Project AGENTS.md](../../../AGENTS.md) — Full project conventions and setup +- [testing/social/domain-tests.md](../../../testing/social/domain-tests.md) — Documented behavior of existing social domain tests (categories, fixtures, error types) +- [testing/social/fixture-data.md](../../../testing/social/fixture-data.md) — Full reference of the shared fixture data model and relationships +- [Vitest documentation](https://vitest.dev/) — Testing framework (`describe`/`it`/`beforeAll`/`beforeEach`/`expect`) diff --git a/.agents/skills/planning/SKILL.md b/.agents/skills/planning/SKILL.md new file mode 100644 index 00000000..101e9cc0 --- /dev/null +++ b/.agents/skills/planning/SKILL.md @@ -0,0 +1,155 @@ +--- +name: planning +description: Turn a feature request, bug report, or refactor into a concrete, reviewable implementation plan before writing code. Use at the start of any non-trivial task in Comify, especially while in plan mode, before handing off to domain-implementation, app-implementation, domain-testing, or code-review. +--- + +# Planning + +## Overview + +Guides turning a vague request ("add X", "fix Y", "support Z") into a concrete, scoped, and reviewable plan for the Comify codebase **before** any code is written. A good plan identifies the bounded context(s) affected, the layers touched (domain, app, tests, deployment segments), the exact files to add/change, the order of operations, and the open questions that need an answer first. This skill is the entry point that feeds into `domain-implementation`, `app-implementation`, `domain-testing`, and `code-review` — it does not duplicate their conventions, it sequences them. + +> **Scope:** Producing and validating a plan. Writing the actual domain/app/test code is out of scope — once a plan is agreed, hand off to the matching implementation skill. + +## When to Use + +Invoke this skill: +- At the start of any task that touches more than one file, more than one layer (domain/app/tests), or more than one bounded context +- Whenever operating in **plan mode** (explore, analyze, align — no edits), before any `act`/`yolo` mode work begins +- When a request is ambiguous about scope, bounded context ownership, or acceptance criteria +- Before a refactor or migration that could ripple across `development/common` and multiple contexts +- When asked to "plan", "design", "scope", or "break down" a feature, or to review an existing plan for gaps + +Skip it only for truly trivial, single-file, single-layer changes (typo fixes, a one-line config tweak) where the implementation skill's own workflow is enough. + +## Prerequisites + +Before producing a plan, gather: + +1. **The raw request** — the user's own words; don't paraphrase away constraints or examples they gave +2. **Repository conventions** — [`AGENTS.md`](../../../AGENTS.md) for code style, project structure, and testing conventions +3. **The relevant implementation skill(s)** — skim `domain-implementation`, `app-implementation`, and `domain-testing` so the plan's steps map onto their actual workflows instead of inventing new ones +4. **Current mode** — plan mode means the output of this skill is the deliverable (no edits); act/yolo mode means the plan is a short internal step before implementing + +## Workflow + +### 1. Clarify the Ask + +1. Restate the goal in one or two sentences and identify the **type** of change: new feature, extension of an existing feature, bug fix, or refactor +2. List any **ambiguities** that materially change scope or design (e.g. "should this be visible to non-owners?", "does this need a new event?", "which bounded context owns this?") +3. If an ambiguity has more than one reasonable answer and picking wrong means rework, use the `ask_question` tool with concrete options instead of assuming — do this **before** exploring further, not after a plan is half-written +4. If the ambiguity is minor (naming, ordering) or has an obvious convention-driven answer, resolve it yourself by following existing patterns and note the assumption in the plan instead of asking + +### 2. Map the Request onto the Codebase + +1. **Identify the bounded context(s)** — `social`, `insights`, `moderation`, `common`, or a new one — using the Project Structure section of `AGENTS.md` and the context map in `documentation/diagrams/context.cml` / `workspace.dsl` if the boundary is unclear +2. **Find the closest existing analog** — search `development//domain` and `development//app/components` for a feature that is structurally similar (same shape of CRUD, same kind of relationship, same kind of form) to mirror rather than invent from scratch +3. **Identify every layer touched**, and only those: + + | Layer | Touched when... | Skill to use | + |-------|------------------|--------------| + | Domain (`development//domain`) | New/changed business logic, records, use cases, events | `domain-implementation` | + | Domain tests (`testing//domain`) | Any new/changed public use case | `domain-testing` | + | App (`development//app`) | New/changed UI, routes, hooks | `app-implementation` | + | Shared (`development/common`) | Cross-context event contracts, shared primitives/utilities | `domain-implementation` conventions, used read-only by other contexts | + | Deployment (`deployment/segments/.*.json`) | Any new/changed public use case or protected helper | Registration step inside `domain-implementation` | + +4. **Check bounded context boundaries** — if the feature seems to need direct access to another context's domain code, it doesn't; route it through a `development/common/domain//` event contract instead (see `domain-implementation`'s Step 6) +5. **Trace dependencies** — note which shared services the feature touches (Keycloak, MongoDB, SeaweedFS, Kafka) and which existing use cases/components it will call or extend, so the plan can sequence "build the thing it depends on" before "build the thing" + +### 3. Decompose into Steps + +Break the work into an ordered, numbered list of concrete steps, each scoped to a single file or tightly-related group of files. Order matters — downstream layers depend on upstream ones: + +1. Domain model/record changes (`definitions.ts`) — if the shape of data is changing +2. Domain use case(s) — one step per use case, inner-to-outer (helpers like `_create`/`_toModel` before the public use case that composes them) +3. Event contract (if a new cross-context event is needed) — defined in `development/common/domain//` before any context publishes/subscribes to it +4. Segment registration — add the new public use case (and any newly extracted protected helper) to the correct `deployment/segments/.*.json` +5. Domain tests — one `.spec.ts` per new/changed public use case, covering happy path, error cases, and ownership/authorization +6. App layer — hooks first (data fetching/mutation), then components, then routes, then barrel (`index.ts`) exports +7. App tests, if introduced for the touched area +8. Manual verification — `npm run lint` and `npm run test` for every affected workspace + +For a small bug fix or single-use-case addition, this list collapses to 2-4 steps; don't pad it artificially. For a multi-context feature, group steps under a subheading per bounded context. + +### 4. Define Acceptance Criteria + +For each major capability in the plan, write a short, checkable acceptance criterion (what should be true when it's done — not how). Reuse the relevant implementation skill's checklist as the baseline instead of restating its contents: + +- Domain work is "done" when `domain-implementation`'s checklist passes +- Tests are "done" when `domain-testing`'s checklist passes +- UI work is "done" when `app-implementation`'s checklist passes +- The whole change is "done" when `code-review`'s checklist passes + +### 5. Surface Risks and Open Questions + +Call out explicitly, rather than glossing over: + +- **Breaking changes** — renamed/removed fields on a `Record` or domain model that existing callers rely on +- **Event compensation** — any new state-changing use case that publishes an event must roll back on publish failure; flag if this adds a new rollback path +- **Migration needs** — new required fields on an existing `Record` type need either a default, a migration step, or an optional (`?`) marker +- **Scope creep candidates** — adjacent improvements that are tempting but not part of this request; list them as "out of scope" rather than silently doing or silently dropping them + +### 6. Present the Plan + +Present the plan using the template below. In **plan mode**, this is the full deliverable — stop here and wait for approval or answers to open questions; do not start editing files. In **act/yolo mode**, present a condensed version (steps + acceptance criteria) inline and proceed directly into execution using the matching implementation skills. + +## Plan Document Template + +```markdown +## Goal + + +## Context +- Bounded context(s): +- Closest existing analog: +- Layers touched: + +## Assumptions / Resolved Ambiguities +- + +## Open Questions +- + +## Steps +1. — +2. ... + +## Acceptance Criteria +- + +## Risks / Out of Scope +- +``` + +## Checklist + +Before presenting a plan as ready, verify: + +- [ ] The request's ambiguities were either resolved via `ask_question` (when consequential) or stated as explicit assumptions (when minor) +- [ ] Every bounded context the plan touches is named, and no step reaches across contexts except through a `development/common/domain//` contract +- [ ] Every step maps to exactly one implementation skill (`domain-implementation`, `app-implementation`, `domain-testing`) and is ordered so dependencies come first +- [ ] Segment registration (`deployment/segments/.*.json`) is called out as a step whenever a new public use case or protected helper is introduced +- [ ] Test coverage (domain `.spec.ts`, and app tests if applicable) is an explicit step, not an afterthought +- [ ] Acceptance criteria are checkable and reuse the implementation skills' own checklists rather than re-describing them +- [ ] Risks (breaking changes, event-publish compensation, migrations) and explicitly out-of-scope adjacent work are called out +- [ ] In plan mode, no files were edited and no state-changing commands were run while producing the plan + +## Common Pitfalls + +1. **Planning in isolation from the codebase** — writing steps from memory instead of first finding the closest existing analog leads to plans that fight the project's conventions (dot-notation features, Record/Domain Model split, barrel exports). +2. **Skipping segment registration** — a plan that adds a use case but forgets the `deployment/segments/*.json` step produces code that compiles and passes local tests but isn't callable. +3. **Under-specifying tests** — "add tests" is not a step; name the spec file path and the scenarios (happy path, error, ownership) it must cover. +4. **Asking too many or too few questions** — batch genuinely blocking ambiguities into one `ask_question` call with concrete options; don't ask about things the codebase's own conventions already answer. +5. **Conflating plan mode with analysis paralysis** — a plan should be proportional to the task; a one-file bug fix doesn't need the full template, just a short restatement and a 2-3 step list. +6. **Ignoring bounded context boundaries in the plan** — a step that says "import from `social` domain into `moderation`" is a design smell the plan should catch and reroute through an event contract, not something to defer to code review. +7. **Treating the plan as a scope guarantee** — if new information discovered mid-implementation invalidates the plan (e.g. the "closest analog" turns out not to fit), revise and re-present it rather than silently diverging. + +## References + +- [Project AGENTS.md](../../../AGENTS.md) — Full project conventions, structure, and setup +- [`.agents/skills/domain-implementation/SKILL.md`](../domain-implementation/SKILL.md) — Domain use-case implementation, consumed by plan steps for backend work +- [`.agents/skills/domain-testing/SKILL.md`](../domain-testing/SKILL.md) — Domain integration test conventions, consumed by plan steps for test coverage +- [`.agents/skills/app-implementation/SKILL.md`](../app-implementation/SKILL.md) — UI feature implementation, consumed by plan steps for frontend work +- [`.agents/skills/code-review/SKILL.md`](../code-review/SKILL.md) — Cross-cutting checklist used to validate the plan's acceptance criteria +- [`documentation/diagrams/context.cml`](../../../documentation/diagrams/context.cml) / [`workspace.dsl`](../../../documentation/diagrams/workspace.dsl) — Bounded context map for unclear ownership questions diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..56087f91 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,195 @@ +# AGENTS.md + +## Project Overview + +Comify is an open-source social media platform that leverages comics for communication. The concept: take or upload a picture, add speech bubbles, and share with others. Comics can be liked, and users can react with comments or other comics. + +**Tech Stack:** +- **Runtime:** Jitar (distributed runtime) +- **Backend:** Node.js with TypeScript (strict mode) +- **Frontend:** React 19 with React Router DOM 7 +- **Database:** MongoDB (via @theshelf/database) +- **Authentication:** Keycloak (OpenID Connect via @theshelf/authentication) +- **File Storage:** SeaweedFS (S3-compatible via @theshelf/files) +- **Event Broker:** Kafka (via @theshelf/events) +- **Bundler:** Vite +- **Testing:** Vitest with coverage (V8) + +**Key Dependencies:** +- `jitar` — core distributed runtime +- `@jitar-plugins/*` — authentication, database, events, files, http, notification +- `@theshelf/*` — authentication, connection, database, events, files, http, logging, notification, validation +- `@maskingtech/*` — designsystem, react-toolkit + +## Agent Skills + +Task-specific guidance lives under `.agents/skills/`. Use the matching skill instead of re-deriving conventions: + +| Skill | Use for | +|-------|---------| +| `domain-implementation` | Implementing domain use cases (business logic, records/models, segment registration) | +| `domain-testing` | Writing/extending `testing//domain/**/*.spec.ts` integration tests | +| `app-implementation` | Implementing frontend/UI features under `development//app` | +| `code-review` | Reviewing code changes for convention, correctness, and test coverage | + +## Prerequisites + +- **Node.js** version 24+ (enforced via `engines` in package.json) +- **Docker** and **docker-compose** for local services +- **npm** package manager (v11.16.0) + +## Setup Commands + +### Initial Setup + +```bash +# 1. Copy environment configuration +cp example.env development.env + +# 2. Install dependencies +npm install + +# 3. Start Docker services +npm run docker-up +``` + +### Build & Run + +```bash +# Build the project (compile + bundle) +npm run build + +# Start the demo application (port 3000) +npm run demo +``` + +### Development + +```bash +# Start backend in development mode +npm run dev + +# Start frontend in development mode (same for other packages) +cd development/social +npm run dev +``` + +### Production Deployment + +```bash +# Build and create distribution +npm run build +npm run dist + +# Start with PM2 using ecosystem config +npm run prod-start + +# Other PM2 commands +npm run prod-stop # Stop all services +npm run prod-down # Delete PM2 process group +npm run prod-list # List running processes +npm run prod-monitor # Monitor resource usage +``` + +## Project Structure + +``` +artifacts/ +├── build/ # Compiled TypeScript code to JavaScript +└── dist/ # Distributable bundles + +deployment/ +├── docker/ # Service configurations (Keycloak, MongoDB, SeaweedFS, Kafka) +├── resources/ # Configuration for shared resource between segments (Jitar) +├── segments/ # Segment configuration for creating the distributable bundles (Jitar) +└── services/ # Service configuration for running the distributable bundles (Jitar) + +development/ +├── common/ # Shared utilities and core modules +├── insights/ # Bounded context for insights/analytics +├── social/ # Bounded context for social features (main app) +├── moderation/ # Bounded context for content moderation +└── */ # Other bounded contexts with source code + +documentation/ +└── diagrams/ # Design diagrams + +testing/ +├── common/ # Tests for shared utilities +├── insights/ # Tests for insights bounded context +├── social/ # Tests for social bounded context +├── moderation/ # Tests for moderation bounded context +└── */ # Mirrors development/ structure +``` + +## Docker Services + +When you run `npm run docker-up`, the following services start: + +| Service | Port | Purpose | +|---------|------|---------| +| Keycloak | 8080 | Authentication (OpenID Connect) | +| MongoDB | 27017 | Data persistence | +| SeaweedFS | 8888 | File/image storage (S3-compatible) | +| Kafka | 9092 | Event streaming | + +Configuration is in `docker-compose.yml`. +Services expose ports on localhost, and data persists via Docker volumes. + +## Code Style + +### General Rules + +- **TypeScript strict mode** enabled +- **Single quotes** only, **always use semicolons** +- **Allman brace style** — opening brace on its own line for functions, conditionals, loops, etc. (`{ ... }` single-line blocks are allowed) +- **Functional patterns** preferred (pure functions, immutable data) +- **Higher-order functions** (`map`, `filter`, `reduce`) over loops +- **Composition** over inheritance +- **No `console.*` calls** (`no-console` is an ESLint error); use the shared `logger` instead +- **One leading blank line** at the top of every file, before the first import +- **One blank line** after the import block +- **One trailing blank line** at the end of file (`eol-last`) +- **No duplicate imports** +- **Inline type imports** preferred (e.g. `import { type Foo } from 'bar'`) +- **Import ordering**: Built-in → External (includes all `@theshelf/*`, `@jitar-plugins/*`, `@maskingtech/*`, `vitest`, etc.) → Internal (`^/*` alias) → Parent (`~/*` alias, and `../`) → Relative (`./`), alphabetized case-insensitively within each group, with a blank line between groups + +### Linting + +- **ESLint** (flat config, `eslint.config.js`) with `@typescript-eslint` (strict + stylistic presets) +- **eslint-plugin-import** for import ordering and module resolution +- **eslint-plugin-react** / **eslint-plugin-react-hooks** for React/JSX linting +- Run with `npm run lint` (per-workspace via `--workspaces`) + +## Testing + +### Framework + +- **Vitest** — unit and integration testing framework, run in the **Node** environment +- Tests live under `testing//...`, mirroring the structure of `development//...` + +### Running Tests + +```bash +npm run test # Run the full suite across all workspaces +npm run test-coverage # Run with V8 coverage +``` + +### Test Conventions + +- **Naming:** `.spec.ts(x)` (e.g., `toggle.spec.ts`, `create.spec.ts`, `login.spec.ts`) +- **Structure:** `describe` → `it` → setup → act → assert +- **Domain tests** (`testing//domain/**`) are **integration tests**: they connect to the real `database`/`eventBroker`/`fileStore` integrations (backed by in-memory/mapped drivers in the test environment), reseed fixture data in `beforeEach`, and never use `vi.mock()`. See the `domain-testing` skill for the full convention. +- **App/UI tests** (`testing//app/**`) mock domain use-case calls and the shared `requester` with `vi.mock()` rather than hitting real integrations. See the `app-implementation` skill. +- **Async code:** Use `await` with standard Vitest assertions (e.g., `expect(...).resolves`, `expect(...).rejects`) +- **Fixtures:** Shared fixtures live in `testing//fixtures`; feature-local fixtures live in a `fixtures/` folder next to the spec files +- **Coverage:** Aim for high coverage on use-cases + +### Environment Variables + +Required variables in `development.env`: + +- `KEYCLOAK_*` — Keycloak authentication settings (URL, realm, client ID, secret) +- `MONGODB_*` — MongoDB connection details (host, port, database name) +- `SEAWEDFS_*` — SeaweedFS storage settings (host, port, bucket name) +- `KAFKA_*` — Kafka broker configuration (host, port, topic names) diff --git a/deployment/docker/Dockerfile b/deployment/docker/Dockerfile index dee18c9a..226223ca 100644 --- a/deployment/docker/Dockerfile +++ b/deployment/docker/Dockerfile @@ -1,7 +1,7 @@ # Comify Docker Image # Build this image from the project root directory -FROM node:24.12-alpine3.22 +FROM node:24.22.1-alpine3.23 # Init root RUN mkdir /opt/app diff --git a/deployment/docker/build.sh b/deployment/docker/build.sh index 7d03f5f4..3d965321 100755 --- a/deployment/docker/build.sh +++ b/deployment/docker/build.sh @@ -1,5 +1,5 @@ # Run me from the project root !! -VERSION=$(jq -r '.version' package.json) +VERSION=$(node -p "require('./package.json').version") docker build -t "localhost:5000/comify:$VERSION" -f deployment/docker/Dockerfile . \ No newline at end of file diff --git a/deployment/docker/push.sh b/deployment/docker/push.sh index 5d54f72b..0425f5df 100755 --- a/deployment/docker/push.sh +++ b/deployment/docker/push.sh @@ -1,5 +1,5 @@ # Run me from the project root !! -VERSION=$(jq -r '.version' package.json) +VERSION=$(node -p "require('./package.json').version") docker push "localhost:5000/comify:$VERSION" \ No newline at end of file diff --git a/deployment/segments/moderation.bff.json b/deployment/segments/moderation.bff.json new file mode 100644 index 00000000..8677ad04 --- /dev/null +++ b/deployment/segments/moderation.bff.json @@ -0,0 +1,3 @@ +{ + "./moderation/domain/report/create": { "default": { "access": "public" } } +} diff --git a/deployment/segments/moderation.errors.json b/deployment/segments/moderation.errors.json new file mode 100644 index 00000000..5909bbca --- /dev/null +++ b/deployment/segments/moderation.errors.json @@ -0,0 +1,3 @@ +{ + "./moderation/domain/report/create/InvalidReport": { "default": {} } +} diff --git a/deployment/segments/moderation.writes.json b/deployment/segments/moderation.writes.json new file mode 100644 index 00000000..f563016d --- /dev/null +++ b/deployment/segments/moderation.writes.json @@ -0,0 +1,3 @@ +{ + "./moderation/domain/report/create/persist": { "default": { "access": "protected" } } +} diff --git a/deployment/segments/social.notification.json b/deployment/segments/social.notification.json index 63b97db7..b3554733 100644 --- a/deployment/segments/social.notification.json +++ b/deployment/segments/social.notification.json @@ -1,3 +1,6 @@ { - "./social/domain/notification/subscriptions": { "default": { "access": "private" } } + "./social/domain/notification/getRecent/retrieve": { "default": { "access": "protected" } }, + + "./social/domain/notification/subscriptions": { "default": { "access": "private" } }, + "./social/domain/notification/removeByPost": { "default": { "access": "private" } } } \ No newline at end of file diff --git a/deployment/segments/social.reads.json b/deployment/segments/social.reads.json index bed86d93..a0adf51c 100644 --- a/deployment/segments/social.reads.json +++ b/deployment/segments/social.reads.json @@ -2,32 +2,47 @@ "./social/domain/creator/create/makeNickname/retrieveByNickname": { "default": { "access": "protected" } }, "./social/domain/creator/create/makeNickname/retrieveByStartNickname": { "default": { "access": "protected" } }, "./social/domain/creator/getByNickname/retrieve": { "default": { "access": "protected" } }, + "./social/domain/creator/getById/retrieve": { "default": { "access": "protected" } }, "./social/domain/creator/getIdByEmail/retrieve": { "default": { "access": "protected" } }, + "./social/domain/creator/getManyById/retrieve": { "default": { "access": "protected" } }, "./social/domain/creator/getOtherIds/retrieve": { "default": { "access": "protected" } }, + "./social/domain/creator/updateNickname/checkAvailability": { "default": { "access": "protected" } }, "./social/domain/creator/updateNickname/retrieveByNickname": { "default": { "access": "protected" } }, "./social/domain/creator.metrics/_retrieveByCreator": { "default": { "access": "protected" } }, + "./social/domain/creator.metrics/get/retrieveExisting": { "default": { "access": "protected" } }, + "./social/domain/creator.metrics/getMany/retrieveExisting": { "default": { "access": "protected" } }, - "./social/domain/image/getById": { "default": { "access": "protected" } }, + "./social/domain/image/getById/retrieveFile": { "default": { "access": "protected" } }, + "./social/domain/image/getById/retrieveRecord": { "default": { "access": "protected" } }, + "./social/domain/image/getManyById/retrieveFiles": { "default": { "access": "protected" } }, + "./social/domain/image/getManyById/retrieveRecords": { "default": { "access": "protected" } }, "./social/domain/post/_retrieveById": { "default": { "access": "protected" } }, "./social/domain/post/explore/retrieve": { "default": { "access": "protected" } }, "./social/domain/post/getByCreator/retrieve": { "default": { "access": "protected" } }, "./social/domain/post/getByFollowing/retrieve": { "default": { "access": "protected" } }, "./social/domain/post/getByParent/retrieve": { "default": { "access": "protected" } }, + "./social/domain/post/getManyById/retrieve": { "default": { "access": "protected" } }, "./social/domain/post/getRecommended/retrieve": { "default": { "access": "protected" } }, "./social/domain/post.comic/_retrieveById": { "default": { "access": "protected" } }, + "./social/domain/post.comic/getManyById/retrieve": { "default": { "access": "protected" } }, "./social/domain/post.comment/getById/retrieve": { "default": { "access": "protected" } }, + "./social/domain/post.comment/getManyById/retrieve": { "default": { "access": "protected" } }, "./social/domain/post.metrics/_retrieveByPost": { "default": { "access": "protected" } }, + "./social/domain/post.metrics/get/retrieveExisting": { "default": { "access": "protected" } }, + "./social/domain/post.metrics/getMany/retrieveExisting": { "default": { "access": "protected" } }, "./social/domain/post.rating/exists/retrieve": { "default": { "access": "protected" } }, + "./social/domain/post.rating/existsMany/retrieve": { "default": { "access": "protected" } }, "./social/domain/post.rating/toggle/retrieve": { "default": { "access": "protected" } }, "./social/domain/relation/_retrieve": { "default": { "access": "protected" } }, "./social/domain/relation/_retrieveFollowing": { "default": { "access": "protected" } }, - "./social/domain/relation/establish/exists": { "default": { "access": "protected" } }, - "./social/domain/relation/getFollowers/retrieve": { "default": { "access": "protected" } } + "./social/domain/relation/establish/exists/retrieve": { "default": { "access": "protected" } }, + "./social/domain/relation/getFollowers/retrieve": { "default": { "access": "protected" } }, + "./social/domain/relation/getMany/retrieveEstablished": { "default": { "access": "protected" } } } \ No newline at end of file diff --git a/deployment/services/demonstration/social.json b/deployment/services/demonstration/social.json index 778d8bf2..ee87cef9 100644 --- a/deployment/services/demonstration/social.json +++ b/deployment/services/demonstration/social.json @@ -28,6 +28,9 @@ "common.bff", "common.errors", "common.reads", + "moderation.bff", + "moderation.errors", + "moderation.writes", "social.bff", "social.errors", "social.notification", @@ -35,7 +38,7 @@ "social.writes" ], - "serveIndexOnNotFound": true, + "fallback": "index.html", "assetRoot": "./social/app", "assets": ["index.html", "appicon32.png", "assets/**/*", "registerSW.js", "sw.js", "workbox-*.js", "manifest.webmanifest"] } diff --git a/deployment/services/development/worker.json b/deployment/services/development/worker.json index 2619445c..a89c48b5 100644 --- a/deployment/services/development/worker.json +++ b/deployment/services/development/worker.json @@ -28,6 +28,9 @@ "common.bff", "common.errors", "common.reads", + "moderation.bff", + "moderation.errors", + "moderation.writes", "social.bff", "social.errors", "social.notification", diff --git a/deployment/services/production/bff.json b/deployment/services/production/bff.json index 3a83ef98..e0590ec0 100644 --- a/deployment/services/production/bff.json +++ b/deployment/services/production/bff.json @@ -20,6 +20,6 @@ { "gateway": "http://127.0.0.1:2000", "trustKey": "${JITAR_TRUST_KEY}", - "segments": ["common.bff", "common.errors", "social.bff", "social.errors"] + "segments": ["common.bff", "common.errors", "moderation.bff", "moderation.errors", "social.bff", "social.errors"] } } \ No newline at end of file diff --git a/deployment/services/production/insights-app.json b/deployment/services/production/insights-app.json index 66e58da1..e07f563c 100644 --- a/deployment/services/production/insights-app.json +++ b/deployment/services/production/insights-app.json @@ -2,7 +2,7 @@ "url": "http://127.0.0.1:1000", "repository": { - "serveIndexOnNotFound": true, + "fallback": "index.html", "assetRoot": "./insights/app", "assets": ["index.html", "appicon32.png", "assets/**/*", "registerSW.js", "sw.js", "workbox-*.js", "manifest.webmanifest" ] } diff --git a/deployment/services/production/moderation-app.json b/deployment/services/production/moderation-app.json index a555f21d..9aceabae 100644 --- a/deployment/services/production/moderation-app.json +++ b/deployment/services/production/moderation-app.json @@ -2,7 +2,7 @@ "url": "http://127.0.0.1:1000", "repository": { - "serveIndexOnNotFound": true, + "fallback": "index.html", "assetRoot": "./moderation/app", "assets": ["index.html", "assets/**/*", "registerSW.js", "sw.js", "workbox-*.js", "manifest.webmanifest" ] } diff --git a/deployment/services/production/notification.json b/deployment/services/production/notification.json index e48fb644..2cb4441b 100644 --- a/deployment/services/production/notification.json +++ b/deployment/services/production/notification.json @@ -1,10 +1,12 @@ { "url": "http://127.0.0.1:4001", "setUp": [ - "./common/integrations/database/setUp" + "./common/integrations/database/setUp", + "./common/integrations/events/setUp" ], "tearDown": [ - "./common/integrations/database/tearDown" + "./common/integrations/database/tearDown", + "./common/integrations/events/tearDown" ], "healthChecks": [ "./common/integrations/database/healthCheck" diff --git a/deployment/services/production/reads.json b/deployment/services/production/reads.json index d95d22e0..4c6c6818 100644 --- a/deployment/services/production/reads.json +++ b/deployment/services/production/reads.json @@ -16,6 +16,6 @@ { "gateway": "http://127.0.0.1:2000", "trustKey": "${JITAR_TRUST_KEY}", - "segments": ["common.reads", "social.reads"] + "segments": ["common.reads", "common.errors", "social.reads", "social.errors"] } } \ No newline at end of file diff --git a/deployment/services/production/reads2.json b/deployment/services/production/reads2.json index e2950ada..2b6778c5 100644 --- a/deployment/services/production/reads2.json +++ b/deployment/services/production/reads2.json @@ -16,6 +16,6 @@ { "gateway": "http://127.0.0.1:2000", "trustKey": "${JITAR_TRUST_KEY}", - "segments": ["common.reads", "social.reads"] + "segments": ["common.reads", "common.errors", "social.reads", "social.errors"] } } \ No newline at end of file diff --git a/deployment/services/production/social-app.json b/deployment/services/production/social-app.json index 3e66ae03..6b601544 100644 --- a/deployment/services/production/social-app.json +++ b/deployment/services/production/social-app.json @@ -2,7 +2,7 @@ "url": "http://127.0.0.1:1000", "repository": { - "serveIndexOnNotFound": true, + "fallback": "index.html", "assetRoot": "./social/app", "assets": ["index.html", "appicon32.png", "assets/**/*", "registerSW.js", "sw.js", "workbox-*.js", "manifest.webmanifest" ] } diff --git a/deployment/services/production/writes.json b/deployment/services/production/writes.json index 55c5085a..d79e0642 100644 --- a/deployment/services/production/writes.json +++ b/deployment/services/production/writes.json @@ -16,6 +16,6 @@ { "gateway": "http://127.0.0.1:2000", "trustKey": "${JITAR_TRUST_KEY}", - "segments": ["social.writes"] + "segments": ["moderation.writes", "social.writes"] } } \ No newline at end of file diff --git a/development/moderation/domain/definitions.ts b/development/moderation/domain/definitions.ts index e69de29b..5a9551d7 100644 --- a/development/moderation/domain/definitions.ts +++ b/development/moderation/domain/definitions.ts @@ -0,0 +1,7 @@ + +export type BaseRecord = { + readonly id: string; +}; + +export const CONTEXT_ID = 'moderation'; + diff --git a/development/moderation/domain/integrations.ts b/development/moderation/domain/integrations.ts new file mode 100644 index 00000000..785e8da9 --- /dev/null +++ b/development/moderation/domain/integrations.ts @@ -0,0 +1,4 @@ + +import appLogger from '@comify/common/integrations/logging'; + +export const domainLogger = appLogger.for('Moderation').for('Domain'); diff --git a/development/moderation/domain/report/create/InvalidReport.ts b/development/moderation/domain/report/create/InvalidReport.ts new file mode 100644 index 00000000..85bd70aa --- /dev/null +++ b/development/moderation/domain/report/create/InvalidReport.ts @@ -0,0 +1,7 @@ + +import { ValidationError } from '@comify/common/integrations/errors'; + +export default class InvalidReport extends ValidationError +{ + +} diff --git a/development/moderation/domain/report/create/createRecord.ts b/development/moderation/domain/report/create/createRecord.ts new file mode 100644 index 00000000..e2717699 --- /dev/null +++ b/development/moderation/domain/report/create/createRecord.ts @@ -0,0 +1,20 @@ + +import { type TenantId } from '@comify/common/domain/tenant'; +import { type Identifier } from '@comify/common/primitives/identifier'; +import generateId from '@comify/common/primitives/identifier/generate'; + +import { type Reason, type Record, type Remark } from '../definitions'; + +export default function (tenantId: TenantId, reporterId: Identifier, postId: Identifier, reason: Reason, remark?: Remark): Record +{ + return { + id: generateId(), + tenantId, + postId, + reporterId, + reason, + remark, + reviewed: false, + createdAt: new Date().toISOString() + }; +} diff --git a/development/moderation/domain/report/create/definitions.ts b/development/moderation/domain/report/create/definitions.ts new file mode 100644 index 00000000..d869e982 --- /dev/null +++ b/development/moderation/domain/report/create/definitions.ts @@ -0,0 +1,10 @@ + +import { type Identifier } from '@comify/common/primitives/identifier'; + +import { type Reason, type Remark } from '../definitions'; + +export type CreateData = { + readonly postId: Identifier; + readonly reason: Reason; + readonly remark?: Remark; +}; diff --git a/development/moderation/domain/report/create/index.ts b/development/moderation/domain/report/create/index.ts new file mode 100644 index 00000000..a4082776 --- /dev/null +++ b/development/moderation/domain/report/create/index.ts @@ -0,0 +1,19 @@ + +import { type Identifier } from '@comify/common/primitives/identifier'; +import { type Requester } from '@comify/common/security'; + +import createRecord from './createRecord'; +import { type CreateData } from './definitions'; +import persist from './persist'; +import validate from './validate'; + +export default async function (requester: Requester, data: CreateData): Promise +{ + validate(data); + + const record = createRecord(requester.tenantId, requester.principalId, data.postId, data.reason, data.remark); + + return persist(record); +} + +export { default as InvalidReport } from './InvalidReport'; diff --git a/development/moderation/domain/report/create/persist.ts b/development/moderation/domain/report/create/persist.ts new file mode 100644 index 00000000..39296b39 --- /dev/null +++ b/development/moderation/domain/report/create/persist.ts @@ -0,0 +1,11 @@ + +import database from '@comify/common/integrations/database'; + +import { RECORD_TYPE, type Record } from '../definitions'; + +export default async function (record: Record): Promise +{ + const result = await database.createRecord(RECORD_TYPE, record); + + return result.recordId; +} diff --git a/development/moderation/domain/report/create/validate.ts b/development/moderation/domain/report/create/validate.ts new file mode 100644 index 00000000..399d67b0 --- /dev/null +++ b/development/moderation/domain/report/create/validate.ts @@ -0,0 +1,22 @@ + +import validator from '@comify/common/integrations/validation'; +import { identifierValidation } from '@comify/common/primitives/identifier'; + +import { reasonValidation, remarkValidation } from '../definitions'; + +import { type CreateData } from './definitions'; +import InvalidReport from './InvalidReport'; + +export default function (data: CreateData): void +{ + const result = validator.validate(data, { + postId: identifierValidation, + reason: reasonValidation, + remark: remarkValidation + }); + + if (result.invalid) + { + throw new InvalidReport(result.messages); + } +} diff --git a/development/moderation/domain/report/definitions.ts b/development/moderation/domain/report/definitions.ts new file mode 100644 index 00000000..be2624a1 --- /dev/null +++ b/development/moderation/domain/report/definitions.ts @@ -0,0 +1,40 @@ + +import { type Validation } from '@theshelf/validation'; + +import { type BaseRecord } from '../definitions'; + +export type Record = BaseRecord & { + readonly tenantId: string; + readonly postId: string; + readonly reporterId: string; + readonly reason: string; + readonly remark?: string; + readonly reviewed: boolean; + readonly createdAt: string; +}; + +export const Reasons = { + SCAM_FRAUD_OR_SPAM: 'scam-fraud-or-spam', + INAPPROPRIATE_CONTENT: 'inappropriate-content' +} as const; + +type ReasonKeys = keyof typeof Reasons; + +export type Reason = typeof Reasons[ReasonKeys]; + +export type Remark = string; + +export const RECORD_TYPE = 'report'; +export const REMARK_MAX_LENGTH = 1000; + +export const reasonValidation: Validation = { + message: 'Value is missing or not a valid reason', + required: true, + ENUM: { values: Object.values(Reasons) } +}; + +export const remarkValidation: Validation = { + message: 'Value is too long', + required: false, + STRING: { maxLength: REMARK_MAX_LENGTH } +}; diff --git a/development/moderation/domain/report/index.ts b/development/moderation/domain/report/index.ts new file mode 100644 index 00000000..1bf4119e --- /dev/null +++ b/development/moderation/domain/report/index.ts @@ -0,0 +1,12 @@ + +export +{ + RECORD_TYPE, + REMARK_MAX_LENGTH, + Reasons, + type Record, + type Reason, + type Remark +} from './definitions'; + +export { default as InvalidReport } from './create/InvalidReport'; diff --git a/development/moderation/domain/report/integrations.ts b/development/moderation/domain/report/integrations.ts new file mode 100644 index 00000000..26008a6d --- /dev/null +++ b/development/moderation/domain/report/integrations.ts @@ -0,0 +1,4 @@ + +import { domainLogger } from '../integrations'; + +export const logger = domainLogger.for('Report'); diff --git a/development/social/app/components/common/components/ReportOption.tsx b/development/social/app/components/common/components/ReportOption.tsx new file mode 100644 index 00000000..8ab051ad --- /dev/null +++ b/development/social/app/components/common/components/ReportOption.tsx @@ -0,0 +1,13 @@ + +import { ClickArea, Icon } from '@maskingtech/designsystem'; + +type Props = { + readonly onClick: () => void; +}; + +export default function ({ onClick }: Props) +{ + return + + ; +} diff --git a/development/social/app/components/common/index.ts b/development/social/app/components/common/index.ts index 7b6bdacd..312b646f 100644 --- a/development/social/app/components/common/index.ts +++ b/development/social/app/components/common/index.ts @@ -2,6 +2,7 @@ // Components export { default as RemoveOption } from './components/RemoveOption'; +export { default as ReportOption } from './components/ReportOption'; export { default as Modal } from './components/Modal'; diff --git a/development/social/app/components/post.report/components/Form.tsx b/development/social/app/components/post.report/components/Form.tsx new file mode 100644 index 00000000..f8708151 --- /dev/null +++ b/development/social/app/components/post.report/components/Form.tsx @@ -0,0 +1,30 @@ + +import { Button, Column, Panel, Row, Select, TextArea } from '@maskingtech/designsystem'; + +import useReportHandler, { type CancelHandler, type ReportHandler } from './hooks/useReportHandler'; + +type Props = { + readonly remarkLimit?: number; + readonly onReport: ReportHandler; + readonly onCancel: CancelHandler; +}; + +const REASON_OPTIONS = new Map(); +REASON_OPTIONS.set('scam-fraud-or-spam', 'Scam, fraud or spam'); +REASON_OPTIONS.set('inappropriate-content', 'Inappropriate content'); + +export default function ({ remarkLimit, onReport, onCancel }: Props) +{ + const [reasonRef, remarkRef, reporting, handleReport, handleCancel] = useReportHandler(onReport, onCancel); + + return + +