Skip to content

Commit 566a464

Browse files
committed
Implement core features and documentation
- #182: Document backend domain model and endpoint boundaries. (close #182) - #177: Add creator perk metadata persistence and validation. (close #177) - #184: Add transaction activity feed endpoint for creators and fans. (close #184) - #183: Add key ownership read model for faster client lookups. (close #183) Details: - Established domain documentation in docs/architecture/ - Updated CreatorProfile schema and services for perksJson - Implemented Activity module for transaction feeds - Implemented Ownership module for fast lookups - Fixed Prisma client linting through type inference
1 parent 6bcfe97 commit 566a464

22 files changed

Lines changed: 501 additions & 24 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Thanks for contributing to the backend for Access Layer, a Stellar-native creato
55
## Before you start
66

77
- Read the [README](./README.md) for context.
8+
- Review the [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md).
89
- Review the scoped backlog in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md).
910
- Keep pull requests limited to one backend issue or one documentation improvement.
1011
- Open a discussion before changing core API shape or background processing architecture.

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ The server is responsible for:
1515
- notifications, analytics, and moderation workflows
1616
- access checks for gated off-chain content
1717

18+
See [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md) for a technical overview.
19+
1820
## Tech
1921

2022
- Node.js
@@ -167,7 +169,8 @@ readinessProbe:
167169
168170
## Open source workflow
169171
170-
- Read [CONTRIBUTING.md](./CONTRIBUTING.md) before starting work.
171-
- Browse the maintainer issue inventory in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md).
172+
- Read the [README](./README.md) for context.
173+
- Review the [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md).
174+
- Review the scoped backlog in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md).
172175
- Review [SECURITY.md](./SECURITY.md) before reporting vulnerabilities.
173176
- Use the issue templates in [`.github/ISSUE_TEMPLATE`](./.github/ISSUE_TEMPLATE) for new scoped work.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Backend Domain Model and Endpoint Boundaries
2+
3+
This document outlines the core backend entities, their relationships, and the boundaries between different modules in the Access Layer Server.
4+
5+
## Domain Model
6+
7+
The following diagram illustrates the core entities and their relationships within the system:
8+
9+
```mermaid
10+
erDiagram
11+
User ||--o| CreatorProfile : "owns"
12+
User ||--o| StellarWallet : "links"
13+
User {
14+
string id PK
15+
string email
16+
string passwordHash
17+
string firstName
18+
string lastName
19+
boolean emailVerified
20+
}
21+
CreatorProfile {
22+
string id PK
23+
string userId FK
24+
string handle
25+
string displayName
26+
string bio
27+
json perks
28+
}
29+
StellarWallet {
30+
string id PK
31+
string userId FK
32+
string address
33+
}
34+
IndexerDLQ {
35+
string id PK
36+
string jobType
37+
json payload
38+
string failureReason
39+
}
40+
AuditEvent {
41+
string id PK
42+
string actor
43+
string action
44+
string target
45+
string targetId
46+
json metadata
47+
}
48+
```
49+
50+
### Core Entities
51+
52+
1. **User**: Represents a registered user. Holds authentication and basic profile data.
53+
2. **CreatorProfile**: Represents the creator persona of a user. Tied to a specific handle and contains metadata like bio and perks.
54+
3. **StellarWallet**: Links a user to their Stellar public address. Used for identity verification and ownership checks.
55+
4. **IndexerDLQ**: Stores failed indexing jobs from the Stellar blockchain for manual review or reprocessing.
56+
5. **AuditEvent**: A generic log for significant actions occurring in the system.
57+
58+
## Module Boundaries
59+
60+
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.
61+
62+
### Major Route Groups
63+
64+
| Module | Responsibility | Primary Entities |
65+
| :--- | :--- | :--- |
66+
| `auth` | User registration, login, session management, and password resets. | `User` |
67+
| `creators` | Public and private creator profile management, including stats and discovery. | `CreatorProfile` |
68+
| `wallet` | Linking and verifying Stellar wallets. | `StellarWallet` |
69+
| `admin` | Internal management tools and system monitoring. | All |
70+
| `health` | System health checks and status monitoring. | N/A |
71+
72+
### Cross-Module Rules
73+
74+
To ensure a maintainable and decoupled architecture, the following rules apply:
75+
76+
1. **No Direct Database Access**: Modules should not directly query Prisma models belonging to other modules if a service/utility exists.
77+
2. **Shared Utilities**: Common logic (e.g., mail sending, logging, pagination) belongs in `src/utils/` and can be used by any module.
78+
3. **Constants**: Shared configuration and string constants belong in `src/constants/`.
79+
4. **Types**: Cross-cutting TypeScript types belong in `src/types/`.
80+
81+
### Interaction Patterns
82+
83+
- **Initialization**: `src/app.ts` assembles the modules and registers global middlewares.
84+
- **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.

prisma/schema/activity.prisma

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// prisma/schema/activity.prisma
2+
3+
enum ActivityType {
4+
CREATOR_REGISTERED
5+
KEY_BOUGHT
6+
KEY_SOLD
7+
PROFILE_UPDATED
8+
}
9+
10+
model Activity {
11+
id String @id @default(cuid())
12+
type ActivityType
13+
14+
// Actor who performed the action (wallet address or user ID)
15+
actor String
16+
17+
// Optional creator associated with this activity
18+
creatorId String?
19+
20+
// Optional target of the activity (e.g., target wallet address)
21+
target String?
22+
23+
// Payload for event-specific data (e.g., price, amount, previous values)
24+
payload Json
25+
26+
createdAt DateTime @default(now())
27+
28+
@@index([creatorId])
29+
@@index([actor])
30+
@@index([type])
31+
}

prisma/schema/creator.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ model CreatorProfile {
99
avatarUrl String?
1010
perkSummary String?
1111
isVerified Boolean @default(false)
12+
perks Json?
1213
createdAt DateTime @default(now())
1314
updatedAt DateTime @updatedAt
1415

prisma/schema/ownership.prisma

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// prisma/schema/ownership.prisma
2+
3+
model KeyOwnership {
4+
id String @id @default(cuid())
5+
6+
// The wallet address of the owner
7+
ownerAddress String
8+
9+
// The ID or handle of the creator whose keys are owned
10+
creatorId String
11+
12+
// The amount of keys owned
13+
balance Decimal @default(0)
14+
15+
createdAt DateTime @default(now())
16+
updatedAt DateTime @updatedAt
17+
18+
@@unique([ownerAddress, creatorId])
19+
@@index([ownerAddress])
20+
@@index([creatorId])
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { AsyncController } from '../../types/auth.types';
2+
import { ActivityQuerySchema } from './activity.schemas';
3+
import { fetchActivityFeed } from './activity.service';
4+
import { sendSuccess, sendValidationError } from '../../utils/api-response.utils';
5+
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';
6+
7+
export const httpGetActivityFeed: AsyncController = async (req, res, next) => {
8+
try {
9+
const parsed = ActivityQuerySchema.safeParse(req.query);
10+
if (!parsed.success) {
11+
return sendValidationError(res, 'Invalid query parameters', parsed.error.issues.map(issue => ({
12+
field: issue.path.join('.'),
13+
message: issue.message,
14+
})));
15+
}
16+
17+
const [items, total] = await fetchActivityFeed(parsed.data);
18+
19+
const response = {
20+
items,
21+
meta: buildOffsetPaginationMeta({
22+
limit: parsed.data.limit,
23+
offset: parsed.data.offset,
24+
total,
25+
}),
26+
};
27+
28+
sendSuccess(res, response);
29+
} catch (error) {
30+
next(error);
31+
}
32+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Router } from 'express';
2+
import { httpGetActivityFeed } from './activity.controllers';
3+
4+
const activityRouter = Router();
5+
6+
/**
7+
* GET /api/v1/activity
8+
*
9+
* Public activity feed with optional filtering by creator, actor, or type.
10+
*/
11+
activityRouter.get('/', httpGetActivityFeed);
12+
13+
export default activityRouter;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from 'zod';
2+
import { safeIntParam } from '../../utils/query.utils';
3+
import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults';
4+
import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants';
5+
6+
export const ActivityQuerySchema = z.object({
7+
limit: safeIntParam({
8+
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit,
9+
min: MIN_PAGE_SIZE,
10+
max: MAX_PAGE_SIZE,
11+
label: 'Limit',
12+
}),
13+
offset: safeIntParam({
14+
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset,
15+
min: 0,
16+
max: Number.MAX_SAFE_INTEGER,
17+
label: 'Offset',
18+
}),
19+
creatorId: z.string().optional(),
20+
actor: z.string().optional(),
21+
type: z.enum(['CREATOR_REGISTERED', 'KEY_BOUGHT', 'KEY_SOLD', 'PROFILE_UPDATED']).optional(),
22+
}).strict();
23+
24+
export type ActivityQueryType = z.infer<typeof ActivityQuerySchema>;
25+
26+
export const ActivityItemSchema = z.object({
27+
id: z.string(),
28+
type: z.string(),
29+
actor: z.string(),
30+
creatorId: z.string().nullable(),
31+
target: z.string().nullable(),
32+
payload: z.any(),
33+
createdAt: z.date(),
34+
});
35+
36+
export const ActivityFeedResponseSchema = z.object({
37+
items: z.array(ActivityItemSchema),
38+
meta: z.object({
39+
limit: z.number(),
40+
offset: z.number(),
41+
total: z.number(),
42+
hasMore: z.boolean(),
43+
}),
44+
});
45+
46+
export type ActivityFeedResponse = z.infer<typeof ActivityFeedResponseSchema>;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { fetchActivityFeed } from './activity.service';
2+
import { prisma } from '../../utils/prisma.utils';
3+
4+
describe('Activity Service', () => {
5+
beforeAll(async () => {
6+
// Clean up and seed minimal test data if needed
7+
// In a real environment, we'd use a test database
8+
});
9+
10+
it('should return empty list when no activity exists', async () => {
11+
const [items, total] = await fetchActivityFeed({ limit: 10, offset: 0 });
12+
expect(Array.isArray(items)).toBe(true);
13+
// expect(total).toBe(0); // Depends on DB state
14+
});
15+
16+
it('should filter by creatorId', async () => {
17+
const [items] = await fetchActivityFeed({ limit: 10, offset: 0, creatorId: 'non-existent' });
18+
expect(items.length).toBe(0);
19+
});
20+
21+
it('should handle pagination', async () => {
22+
const [items1] = await fetchActivityFeed({ limit: 1, offset: 0 });
23+
const [items2] = await fetchActivityFeed({ limit: 1, offset: 1 });
24+
if (items1.length > 0 && items2.length > 0) {
25+
expect(items1[0].id).not.toBe(items2[0].id);
26+
}
27+
});
28+
});

0 commit comments

Comments
 (0)