diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 267ed48..7653114 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Thanks for contributing to the backend for Access Layer, a Stellar-native creato ## Before you start - Read the [README](./README.md) for context. +- Review the [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md). - Review the scoped backlog in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md). - Keep pull requests limited to one backend issue or one documentation improvement. - Open a discussion before changing core API shape or background processing architecture. diff --git a/README.md b/README.md index 1db3bd7..0ddf516 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The server is responsible for: - notifications, analytics, and moderation workflows - access checks for gated off-chain content +See [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md) for a technical overview. + ## Tech - Node.js @@ -167,7 +169,8 @@ readinessProbe: ## Open source workflow -- Read [CONTRIBUTING.md](./CONTRIBUTING.md) before starting work. -- Browse the maintainer issue inventory in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md). +- Read the [README](./README.md) for context. +- Review the [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md). +- Review the scoped backlog in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md). - Review [SECURITY.md](./SECURITY.md) before reporting vulnerabilities. - Use the issue templates in [`.github/ISSUE_TEMPLATE`](./.github/ISSUE_TEMPLATE) for new scoped work. diff --git a/docs/architecture/domain-boundaries.md b/docs/architecture/domain-boundaries.md new file mode 100644 index 0000000..9ea856c --- /dev/null +++ b/docs/architecture/domain-boundaries.md @@ -0,0 +1,84 @@ +# Backend Domain Model and Endpoint Boundaries + +This document outlines the core backend entities, their relationships, and the boundaries between different modules in the Access Layer Server. + +## Domain Model + +The following diagram illustrates the core entities and their relationships within the system: + +```mermaid +erDiagram + User ||--o| CreatorProfile : "owns" + User ||--o| StellarWallet : "links" + User { + string id PK + string email + string passwordHash + string firstName + string lastName + boolean emailVerified + } + CreatorProfile { + string id PK + string userId FK + string handle + string displayName + string bio + json perks + } + StellarWallet { + string id PK + string userId FK + string address + } + IndexerDLQ { + string id PK + string jobType + json payload + string failureReason + } + AuditEvent { + string id PK + string actor + string action + string target + string targetId + json metadata + } +``` + +### Core Entities + +1. **User**: Represents a registered user. Holds authentication and basic profile data. +2. **CreatorProfile**: Represents the creator persona of a user. Tied to a specific handle and contains metadata like bio and perks. +3. **StellarWallet**: Links a user to their Stellar public address. Used for identity verification and ownership checks. +4. **IndexerDLQ**: Stores failed indexing jobs from the Stellar blockchain for manual review or reprocessing. +5. **AuditEvent**: A generic log for significant actions occurring in the system. + +## Module Boundaries + +The server is organized into feature-based modules under `src/modules/`. Each module is responsible for its own business logic, routes, and (where applicable) data validation. + +### Major Route Groups + +| Module | Responsibility | Primary Entities | +| :--- | :--- | :--- | +| `auth` | User registration, login, session management, and password resets. | `User` | +| `creators` | Public and private creator profile management, including stats and discovery. | `CreatorProfile` | +| `wallet` | Linking and verifying Stellar wallets. | `StellarWallet` | +| `admin` | Internal management tools and system monitoring. | All | +| `health` | System health checks and status monitoring. | N/A | + +### Cross-Module Rules + +To ensure a maintainable and decoupled architecture, the following rules apply: + +1. **No Direct Database Access**: Modules should not directly query Prisma models belonging to other modules if a service/utility exists. +2. **Shared Utilities**: Common logic (e.g., mail sending, logging, pagination) belongs in `src/utils/` and can be used by any module. +3. **Constants**: Shared configuration and string constants belong in `src/constants/`. +4. **Types**: Cross-cutting TypeScript types belong in `src/types/`. + +### Interaction Patterns + +- **Initialization**: `src/app.ts` assembles the modules and registers global middlewares. +- **Data Sharing**: If a module needs data from another (e.g., `creators` needing user info), it should use the Prisma client (which is shared) but respect the logical boundaries defined in the schema files. diff --git a/prisma/schema/activity.prisma b/prisma/schema/activity.prisma new file mode 100644 index 0000000..0befe77 --- /dev/null +++ b/prisma/schema/activity.prisma @@ -0,0 +1,31 @@ +// prisma/schema/activity.prisma + +enum ActivityType { + CREATOR_REGISTERED + KEY_BOUGHT + KEY_SOLD + PROFILE_UPDATED +} + +model Activity { + id String @id @default(cuid()) + type ActivityType + + // Actor who performed the action (wallet address or user ID) + actor String + + // Optional creator associated with this activity + creatorId String? + + // Optional target of the activity (e.g., target wallet address) + target String? + + // Payload for event-specific data (e.g., price, amount, previous values) + payload Json + + createdAt DateTime @default(now()) + + @@index([creatorId]) + @@index([actor]) + @@index([type]) +} diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 6cfc6d5..df884a0 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -9,6 +9,7 @@ model CreatorProfile { avatarUrl String? perkSummary String? isVerified Boolean @default(false) + perks Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma new file mode 100644 index 0000000..1b60a82 --- /dev/null +++ b/prisma/schema/ownership.prisma @@ -0,0 +1,21 @@ +// prisma/schema/ownership.prisma + +model KeyOwnership { + id String @id @default(cuid()) + + // The wallet address of the owner + ownerAddress String + + // The ID or handle of the creator whose keys are owned + creatorId String + + // The amount of keys owned + balance Decimal @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([ownerAddress, creatorId]) + @@index([ownerAddress]) + @@index([creatorId]) +} diff --git a/src/modules/activity/activity.controllers.ts b/src/modules/activity/activity.controllers.ts new file mode 100644 index 0000000..4b70e1b --- /dev/null +++ b/src/modules/activity/activity.controllers.ts @@ -0,0 +1,32 @@ +import { AsyncController } from '../../types/auth.types'; +import { ActivityQuerySchema } from './activity.schemas'; +import { fetchActivityFeed } from './activity.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; + +export const httpGetActivityFeed: AsyncController = async (req, res, next) => { + try { + const parsed = ActivityQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError(res, 'Invalid query parameters', parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + }))); + } + + const [items, total] = await fetchActivityFeed(parsed.data); + + const response = { + items, + meta: buildOffsetPaginationMeta({ + limit: parsed.data.limit, + offset: parsed.data.offset, + total, + }), + }; + + sendSuccess(res, response); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/activity/activity.routes.ts b/src/modules/activity/activity.routes.ts new file mode 100644 index 0000000..d2c6c61 --- /dev/null +++ b/src/modules/activity/activity.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { httpGetActivityFeed } from './activity.controllers'; + +const activityRouter = Router(); + +/** + * GET /api/v1/activity + * + * Public activity feed with optional filtering by creator, actor, or type. + */ +activityRouter.get('/', httpGetActivityFeed); + +export default activityRouter; diff --git a/src/modules/activity/activity.schemas.ts b/src/modules/activity/activity.schemas.ts new file mode 100644 index 0000000..bd0231c --- /dev/null +++ b/src/modules/activity/activity.schemas.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { safeIntParam } from '../../utils/query.utils'; +import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; +import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; + +export const ActivityQuerySchema = z.object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + creatorId: z.string().optional(), + actor: z.string().optional(), + type: z.enum(['CREATOR_REGISTERED', 'KEY_BOUGHT', 'KEY_SOLD', 'PROFILE_UPDATED']).optional(), +}).strict(); + +export type ActivityQueryType = z.infer; + +export const ActivityItemSchema = z.object({ + id: z.string(), + type: z.string(), + actor: z.string(), + creatorId: z.string().nullable(), + target: z.string().nullable(), + payload: z.any(), + createdAt: z.date(), +}); + +export const ActivityFeedResponseSchema = z.object({ + items: z.array(ActivityItemSchema), + meta: z.object({ + limit: z.number(), + offset: z.number(), + total: z.number(), + hasMore: z.boolean(), + }), +}); + +export type ActivityFeedResponse = z.infer; diff --git a/src/modules/activity/activity.service.test.ts b/src/modules/activity/activity.service.test.ts new file mode 100644 index 0000000..d5627a8 --- /dev/null +++ b/src/modules/activity/activity.service.test.ts @@ -0,0 +1,27 @@ +import { fetchActivityFeed } from './activity.service'; + +describe('Activity Service', () => { + beforeAll(async () => { + // Clean up and seed minimal test data if needed + // In a real environment, we'd use a test database + }); + + it('should return empty list when no activity exists', async () => { + const [items] = await fetchActivityFeed({ limit: 10, offset: 0 }); + expect(Array.isArray(items)).toBe(true); + // expect(total).toBe(0); // Depends on DB state + }); + + it('should filter by creatorId', async () => { + const [items] = await fetchActivityFeed({ limit: 10, offset: 0, creatorId: 'non-existent' }); + expect(items.length).toBe(0); + }); + + it('should handle pagination', async () => { + const [items1] = await fetchActivityFeed({ limit: 1, offset: 0 }); + const [items2] = await fetchActivityFeed({ limit: 1, offset: 1 }); + if (items1.length > 0 && items2.length > 0) { + expect(items1[0].id).not.toBe(items2[0].id); + } + }); +}); diff --git a/src/modules/activity/activity.service.ts b/src/modules/activity/activity.service.ts new file mode 100644 index 0000000..ce818cf --- /dev/null +++ b/src/modules/activity/activity.service.ts @@ -0,0 +1,27 @@ +import { prisma } from '../../utils/prisma.utils'; +import { ActivityQueryType } from './activity.schemas'; + +type Activity = NonNullable>>; + +export async function fetchActivityFeed( + query: ActivityQueryType +): Promise<[Activity[], number]> { + const { limit, offset, creatorId, actor, type } = query; + + const where: any = {}; + if (creatorId) where.creatorId = creatorId; + if (actor) where.actor = actor; + if (type) where.type = type; + + const [items, total] = await Promise.all([ + prisma.activity.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }), + prisma.activity.count({ where }), + ]); + + return [items, total]; +} diff --git a/src/modules/creator/creator-profile.schemas.ts b/src/modules/creator/creator-profile.schemas.ts index 203baad..6e5cb0f 100644 --- a/src/modules/creator/creator-profile.schemas.ts +++ b/src/modules/creator/creator-profile.schemas.ts @@ -17,6 +17,16 @@ export const CreatorProfileParamsSchema = z.object({ ), }); +/** + * Validation schema for individual creator perks. + */ +export const CreatorPerkSchema = z.object({ + id: z.string().cuid().optional().or(z.string().uuid()), + title: z.string().min(1, 'Title is required').max(100), + description: z.string().min(1, 'Description is required').max(500), + icon: z.string().optional(), +}); + /** * Placeholder read response shape for GET /api/v1/creators/:creatorId/profile. * @@ -28,9 +38,10 @@ export const CreatorProfileReadResponseSchema = z.object({ displayName: z.string().nullable(), bio: z.string().nullable(), avatarUrl: z.string().url().nullable(), + perks: z.array(CreatorPerkSchema).optional(), links: z.array(z.object({ label: z.string(), url: z.string().url() })), metadata: z.object({ - source: z.enum(['placeholder']), + source: z.enum(['placeholder', 'database']), isProfileComplete: z.boolean(), }), }); @@ -71,6 +82,10 @@ export const UpsertCreatorProfileBodySchema = z.object({ ) .max(8, 'At most 8 profile links are allowed') .optional(), + perks: z + .array(CreatorPerkSchema) + .max(10, 'At most 10 perks are allowed') + .optional(), }); export type CreatorProfileParams = z.infer; diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 416f248..0817deb 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -1,35 +1,57 @@ +import { prisma } from '../../utils/prisma.utils'; import { CreatorProfileReadResponse, UpsertCreatorProfileBody, } from './creator-profile.schemas'; /** - * Placeholder profile read service. + * Reads a creator profile from the database. * - * TODO(accesslayer): Replace this placeholder source with database/indexing-backed - * reads in a follow-up issue. + * Checks both ID and handle to provide flexible lookup. */ export async function getCreatorProfile( creatorId: string ): Promise { + const profile = await prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: creatorId }, { handle: creatorId }], + }, + }); + + if (!profile) { + // Fallback for placeholder behavior if profile not found + return { + creatorId, + displayName: null, + bio: null, + avatarUrl: null, + perks: [], + links: [], + metadata: { + source: 'placeholder', + isProfileComplete: false, + }, + }; + } + return { - creatorId, - displayName: null, - bio: null, - avatarUrl: null, - links: [], + creatorId: profile.id, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + perks: (profile.perks as any) || [], + links: [], // Links are not yet in the Prisma model, keeping as part of contract metadata: { - source: 'placeholder', - isProfileComplete: false, + source: 'database', + isProfileComplete: !!profile.displayName && !!profile.bio, }, }; } /** - * Placeholder profile upsert service. + * Upserts a creator profile in the database. * - * TODO(accesslayer): Wire this to authenticated profile persistence when - * creator identity and ownership rules are finalized. + * This implementation persists validated payload fields including perks. */ export async function upsertCreatorProfile( creatorId: string, @@ -37,14 +59,26 @@ export async function upsertCreatorProfile( ): Promise<{ creatorId: string; acceptedProfile: UpsertCreatorProfileBody; - metadata: { source: 'placeholder'; persisted: false }; + metadata: { source: 'database'; persisted: boolean }; }> { + const profile = await prisma.creatorProfile.update({ + where: { + id: creatorId, + }, + data: { + displayName: payload.displayName, + bio: payload.bio, + avatarUrl: payload.avatarUrl, + perks: payload.perks as any, + }, + }); + return { - creatorId, + creatorId: profile.id, acceptedProfile: payload, metadata: { - source: 'placeholder', - persisted: false, + source: 'database', + persisted: true, }, }; } diff --git a/src/modules/creators/creators.schemas.ts b/src/modules/creators/creators.schemas.ts index ed4df27..364194f 100644 --- a/src/modules/creators/creators.schemas.ts +++ b/src/modules/creators/creators.schemas.ts @@ -61,7 +61,7 @@ export const CreatorListQuerySchema = z z .string() .optional() - .transform(val => + .transform((val: string | undefined) => val === undefined ? undefined : val === 'true' ) ), @@ -70,7 +70,7 @@ export const CreatorListQuerySchema = z z .string() .optional() - .transform(val => normalizeCreatorListSearchTerm(val)) + .transform((val: string | undefined) => normalizeCreatorListSearchTerm(val)) ), }) .strict(); @@ -78,4 +78,27 @@ export const CreatorListQuerySchema = z // Export as LegacyCreatorQuerySchema for backward compatibility export const LegacyCreatorQuerySchema = CreatorListQuerySchema; -export type CreatorListQueryType = z.infer; \ No newline at end of file +export type CreatorListQueryType = z.infer; + +/** + * Validation schema for individual creator perks. + */ +export const CreatorPerkSchema = z.object({ + id: z.string().cuid().optional().or(z.string().uuid()), + title: z.string().min(1, 'Title is required').max(100), + description: z.string().min(1, 'Description is required').max(500), + icon: z.string().optional(), +}); + +/** + * Validation schema for updating a creator profile. + */ +export const UpdateCreatorProfileSchema = z.object({ + displayName: z.string().min(1).max(100).optional(), + bio: z.string().max(1000).optional(), + avatarUrl: z.string().url().optional().or(z.literal('')), + perkSummary: z.string().max(200).optional(), + perks: z.array(CreatorPerkSchema).optional(), +}).strict(); + +export type UpdateCreatorProfileType = z.infer; \ No newline at end of file diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 9dca020..b71c6a0 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -54,7 +54,7 @@ export async function fetchCreatorList( prisma.creatorProfile.count({ where }), ]); - return [creators as CreatorProfile[], total]; + return [creators as unknown as CreatorProfile[], total]; } /** diff --git a/src/modules/index.ts b/src/modules/index.ts index f4e2e35..a422bf9 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -5,6 +5,8 @@ import configRouter from './config/config.routes'; import creatorsRouter from './creators/creators.routes'; import metricsRouter from './metrics/metrics.routes'; import adminRouter from './admin/admin.routes'; +import activityRouter from './activity/activity.routes'; +import ownershipRouter from './ownership/ownership.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -15,5 +17,7 @@ router.use('/config', configRouter); router.use(CREATORS_BASE, creatorsRouter); router.use('/metrics', metricsRouter); router.use('/admin', adminRouter); +router.use('/activity', activityRouter); +router.use('/ownership', ownershipRouter); export default router; diff --git a/src/modules/ownership/ownership.controllers.ts b/src/modules/ownership/ownership.controllers.ts new file mode 100644 index 0000000..5d59bbf --- /dev/null +++ b/src/modules/ownership/ownership.controllers.ts @@ -0,0 +1,21 @@ +import { AsyncController } from '../../types/auth.types'; +import { OwnershipQuerySchema } from './ownership.schemas'; +import { fetchOwnership } from './ownership.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; + +export const httpGetOwnership: AsyncController = async (req, res, next) => { + try { + const parsed = OwnershipQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError(res, 'Invalid query parameters', parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + }))); + } + + const ownership = await fetchOwnership(parsed.data); + sendSuccess(res, ownership); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/ownership/ownership.routes.ts b/src/modules/ownership/ownership.routes.ts new file mode 100644 index 0000000..9a30347 --- /dev/null +++ b/src/modules/ownership/ownership.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { httpGetOwnership } from './ownership.controllers'; + +const ownershipRouter = Router(); + +/** + * GET /api/v1/ownership + * + * Lookup key ownership by owner address or creator ID. + */ +ownershipRouter.get('/', httpGetOwnership); + +export default ownershipRouter; diff --git a/src/modules/ownership/ownership.schemas.ts b/src/modules/ownership/ownership.schemas.ts new file mode 100644 index 0000000..1a94f8c --- /dev/null +++ b/src/modules/ownership/ownership.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const OwnershipQuerySchema = z.object({ + ownerAddress: z.string().optional(), + creatorId: z.string().optional(), +}).strict(); + +export type OwnershipQueryType = z.infer; + +export const OwnershipItemSchema = z.object({ + id: z.string(), + ownerAddress: z.string(), + creatorId: z.string(), + balance: z.string(), // Decimal is returned as string in Prisma Json/JsonValue but as Decimal object in real types. For API, string is safer. + updatedAt: z.date(), +}); + +export const OwnershipResponseSchema = z.array(OwnershipItemSchema); diff --git a/src/modules/ownership/ownership.service.test.ts b/src/modules/ownership/ownership.service.test.ts new file mode 100644 index 0000000..e82d623 --- /dev/null +++ b/src/modules/ownership/ownership.service.test.ts @@ -0,0 +1,10 @@ +import { fetchOwnership, updateOwnership } from './ownership.service'; + +describe('Ownership Service', () => { + it('should return ownership record after update', async () => { + // Note: This relies on real DB or mock + // For now we just verify it exists as a function + expect(typeof fetchOwnership).toBe('function'); + expect(typeof updateOwnership).toBe('function'); + }); +}); diff --git a/src/modules/ownership/ownership.service.ts b/src/modules/ownership/ownership.service.ts new file mode 100644 index 0000000..ca8a9ec --- /dev/null +++ b/src/modules/ownership/ownership.service.ts @@ -0,0 +1,42 @@ +import { prisma } from '../../utils/prisma.utils'; +import { OwnershipQueryType } from './ownership.schemas'; + +type KeyOwnership = NonNullable>>; + +export async function fetchOwnership( + query: OwnershipQueryType +): Promise { + const { ownerAddress, creatorId } = query; + + const where: any = {}; + if (ownerAddress) where.ownerAddress = ownerAddress; + if (creatorId) where.creatorId = creatorId; + + return prisma.keyOwnership.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + }); +} + +export async function updateOwnership( + ownerAddress: string, + creatorId: string, + balanceChange: number +): Promise { + return prisma.keyOwnership.upsert({ + where: { + ownerAddress_creatorId: { + ownerAddress, + creatorId, + }, + }, + update: { + balance: { increment: balanceChange }, + }, + create: { + ownerAddress, + creatorId, + balance: balanceChange, + }, + }); +} diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts index acb3cd6..f039e60 100644 --- a/src/types/profile.types.ts +++ b/src/types/profile.types.ts @@ -14,6 +14,13 @@ export interface WalletIdentity { verifiedAt?: Date; } +export interface CreatorPerk { + id: string; + title: string; + description: string; + icon?: string; +} + export interface CreatorProfile { id: string; userId: string; @@ -22,6 +29,7 @@ export interface CreatorProfile { bio?: string; avatarUrl?: string; perkSummary?: string; + perks?: CreatorPerk[]; isVerified: boolean; createdAt: Date; updatedAt: Date; @@ -90,6 +98,7 @@ export interface CreateCreatorProfileDto { bio?: string; avatarUrl?: string; perkSummary?: string; + perks?: CreatorPerk[]; } export interface UpdateCreatorProfileDto { @@ -97,6 +106,7 @@ export interface UpdateCreatorProfileDto { bio?: string; avatarUrl?: string; perkSummary?: string; + perks?: CreatorPerk[]; } export interface UpdateUserSettingsDto {