Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
84 changes: 84 additions & 0 deletions docs/architecture/domain-boundaries.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions prisma/schema/activity.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
1 change: 1 addition & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ model CreatorProfile {
avatarUrl String?
perkSummary String?
isVerified Boolean @default(false)
perks Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
21 changes: 21 additions & 0 deletions prisma/schema/ownership.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
32 changes: 32 additions & 0 deletions src/modules/activity/activity.controllers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
13 changes: 13 additions & 0 deletions src/modules/activity/activity.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
46 changes: 46 additions & 0 deletions src/modules/activity/activity.schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ActivityQuerySchema>;

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<typeof ActivityFeedResponseSchema>;
27 changes: 27 additions & 0 deletions src/modules/activity/activity.service.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
27 changes: 27 additions & 0 deletions src/modules/activity/activity.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { prisma } from '../../utils/prisma.utils';
import { ActivityQueryType } from './activity.schemas';

type Activity = NonNullable<Awaited<ReturnType<typeof prisma.activity.findFirst>>>;

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];
}
17 changes: 16 additions & 1 deletion src/modules/creator/creator-profile.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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(),
}),
});
Expand Down Expand Up @@ -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<typeof CreatorProfileParamsSchema>;
Expand Down
Loading
Loading