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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tipReceived" BOOLEAN NOT NULL DEFAULT true,
"goalReached" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");

-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
111 changes: 52 additions & 59 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,47 @@ datasource db {
/// A creator/user identified by their Stellar wallet address.
/// Extended by credit, tips and other modules in later issues.
model User {
id String @id @default(cuid())
stellarAddress String @unique
username String? @unique
displayName String?
bio String?
imageUrl String?
avatarCid String?
xHandle String?
role String @default("user")
scopes String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiKeys ApiKey[]
refreshTokens RefreshToken[]
goals Goal[]
sentTips Tip[] @relation("SentTips")
receivedTips Tip[] @relation("ReceivedTips")
leaderboardSnapshots LeaderboardSnapshot[]
streak Streak?
notifications Notification[]

tipperSubscriptions Subscription[] @relation("SubscriptionTipper")
creatorSubscriptions Subscription[] @relation("SubscriptionCreator")
id String @id @default(cuid())
stellarAddress String @unique
username String? @unique
displayName String?
bio String?
imageUrl String?
avatarCid String?
xHandle String?
role String @default("user")
scopes String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Soft-delete marker: non-null means the record is logically deleted.
deletedAt DateTime?
apiKeys ApiKey[]
refreshTokens RefreshToken[]
goals Goal[]
sentTips Tip[] @relation("SentTips")
receivedTips Tip[] @relation("ReceivedTips")
tipperSubscriptions Subscription[] @relation("SubscriptionTipper")
creatorSubscriptions Subscription[] @relation("SubscriptionCreator")
creditScore CreditScore?
creditScoreHistory CreditScoreHistory[]
withdrawals Withdrawal[]
notifications Notification[]
leaderboardSnapshots LeaderboardSnapshot[]
streak Streak?
deletedAt DateTime?
apiKeys ApiKey[]
refreshTokens RefreshToken[]
goals Goal[]
sentTips Tip[] @relation("SentTips")
receivedTips Tip[] @relation("ReceivedTips")
tipperSubscriptions Subscription[] @relation("SubscriptionTipper")
creatorSubscriptions Subscription[] @relation("SubscriptionCreator")
creditScore CreditScore?
creditScoreHistory CreditScoreHistory[]
withdrawals Withdrawal[]
notifications Notification[]
notificationPreference NotificationPreference?
leaderboardSnapshots LeaderboardSnapshot[]
streak Streak?

@@index([createdAt])
}

/// API key for service/admin access and webhook integrations.
model ApiKey {
id String @id @default(cuid())
hashedKey String @unique
id String @id @default(cuid())
hashedKey String @unique
scopes String[]
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Soft-delete marker: non-null means the record is logically deleted.
deletedAt DateTime?

Expand Down Expand Up @@ -98,8 +88,8 @@ model Tip {
updatedAt DateTime @updatedAt
refund Refund?

sender User? @relation("SentTips", fields: [fromAddress], references: [stellarAddress])
receiver User? @relation("ReceivedTips", fields: [toAddress], references: [stellarAddress])
sender User? @relation("SentTips", fields: [fromAddress], references: [stellarAddress])
receiver User? @relation("ReceivedTips", fields: [toAddress], references: [stellarAddress])

@@index([toAddress, createdAt])
@@index([fromAddress, createdAt])
Expand Down Expand Up @@ -189,6 +179,18 @@ model Notification {
@@index([createdAt])
}

/// Per-user toggles controlling which notification types are created for them.
/// Missing rows are treated as all-enabled (see notifications module defaults).
model NotificationPreference {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tipReceived Boolean @default(true)
goalReached Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

/// Cached X (Twitter) account metrics.
model XAccount {
id String @id @default(cuid())
Expand All @@ -200,13 +202,6 @@ model XAccount {
updatedAt DateTime @updatedAt
}

/// Status of a tip transaction.
enum TipStatus {
CONFIRMED
PENDING
REFUNDED
}

/// Leaderboard period tracked by snapshots.
enum Period {
WEEKLY
Expand Down Expand Up @@ -244,16 +239,16 @@ model Streak {
/// Created when a wallet requests a sign-in nonce; deleted (or expired) after verification.
/// TTL is enforced by the application layer via AUTH_CHALLENGE_TTL_SECONDS.
model AuthChallenge {
id String @id @default(cuid())
id String @id @default(cuid())
/// The wallet address this challenge was issued for.
stellarAddress String
stellarAddress String
/// Random nonce the wallet must sign to prove ownership.
challenge String @unique
challenge String @unique
/// Network this challenge is bound to (TESTNET, FUTURENET, MAINNET).
network String
network String
/// UTC timestamp after which the challenge is no longer valid.
expiresAt DateTime
usedAt DateTime?
expiresAt DateTime
usedAt DateTime?

@@index([stellarAddress])
@@index([expiresAt])
Expand Down Expand Up @@ -420,5 +415,3 @@ model AuditLog {
@@index([action])
@@index([createdAt])
}


17 changes: 1 addition & 16 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,7 @@ import helmet from 'helmet';
import pinoHttp from 'pino-http';
import swaggerUi from 'swagger-ui-express';
import { env } from './config/env.js';
import {
errorHandler,
notFoundHandler,
} from "./common/middleware/errorHandler.js";
import { logger } from "./common/utils/logger.js";
import { openApiDocument } from "./docs/openapi.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { profilesRouter } from "./modules/profiles/profiles.routes.js";
import { creditRouter } from "./modules/credit/credit.routes.js";
import { leaderboardRouter } from "./modules/leaderboard/leaderboard.routes.js";
import { xRouter } from "./modules/x/x.routes.js";
} from './common/middleware/errorHandler.js';
import { errorHandler, notFoundHandler } from './common/middleware/errorHandler.js';
import { logger } from './common/utils/logger.js';
import { openApiDocument } from './docs/openapi.js';
import { requestId } from './common/middleware/requestId.js';
Expand Down Expand Up @@ -67,10 +56,6 @@ export function createApp(): Express {
app.use(`${env.API_BASE_PATH}/profiles`, profilesRouter);
app.use(`${env.API_BASE_PATH}/credit`, creditRouter);
app.use(`${env.API_BASE_PATH}/leaderboard`, leaderboardRouter);
app.use(`${env.API_BASE_PATH}/x`, xRouter);
// app.use(`${env.API_BASE_PATH}/tips`, tipsRouter);
// ... (one issue per module)
// ─────────────────────────────────────────────────────────────
app.use(`${env.API_BASE_PATH}/ipfs`, ipfsRouter);
app.use(`${env.API_BASE_PATH}/tips`, tipsRouter);
app.use(`${env.API_BASE_PATH}/withdrawals`, withdrawalsRouter);
Expand Down
5 changes: 0 additions & 5 deletions backend/src/common/errors/AppError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ export class ConflictError extends AppError {
super(409, message, "CONFLICT");
}
}
export class ServiceUnavailableError extends AppError {
constructor(message = "Service unavailable") {
super(503, message, "SERVICE_UNAVAILABLE");
}
}

export class BadGatewayError extends AppError {
constructor(message = 'Bad gateway', details?: unknown) {
Expand Down
12 changes: 11 additions & 1 deletion backend/src/indexer/indexer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
mockUserUpsert,
mockGoalUpsert,
mockGoalUpdateMany,
mockGoalFindUnique,
mockSubUpsert,
mockSubUpdateMany,
mockTipUpsert,
Expand All @@ -44,10 +45,12 @@ const {
mockCreditScoreUpsert,
mockCreditScoreHistoryUpsert,
mockPublishProjection,
mockCreateNotification,
} = vi.hoisted(() => ({
mockUserUpsert: vi.fn(),
mockGoalUpsert: vi.fn(),
mockGoalUpdateMany: vi.fn(),
mockGoalFindUnique: vi.fn(),
mockSubUpsert: vi.fn(),
mockSubUpdateMany: vi.fn(),
mockTipUpsert: vi.fn(),
Expand All @@ -56,12 +59,13 @@ const {
mockCreditScoreUpsert: vi.fn(),
mockCreditScoreHistoryUpsert: vi.fn(),
mockPublishProjection: vi.fn(),
mockCreateNotification: vi.fn(),
}));

vi.mock('../db/prisma.js', () => ({
prisma: {
user: { upsert: mockUserUpsert },
goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany },
goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany, findUnique: mockGoalFindUnique },
subscription: { upsert: mockSubUpsert, updateMany: mockSubUpdateMany },
tip: { upsert: mockTipUpsert },
eventLog: { findFirst: mockEventLogFindFirst, create: mockEventLogCreate },
Expand All @@ -74,6 +78,10 @@ vi.mock('./realtime-publisher.js', () => ({
publishProjection: mockPublishProjection,
}));

vi.mock('../modules/notifications/notifications.service.js', () => ({
createNotification: mockCreateNotification,
}));

// ── Helpers ───────────────────────────────────────────────────────────────────

import { projectEvent } from './projections.js';
Expand Down Expand Up @@ -105,11 +113,13 @@ beforeEach(() => {
mockTipUpsert.mockResolvedValue({});
mockGoalUpsert.mockResolvedValue({});
mockGoalUpdateMany.mockResolvedValue({ count: 1 });
mockGoalFindUnique.mockResolvedValue(null);
mockSubUpsert.mockResolvedValue({});
mockSubUpdateMany.mockResolvedValue({ count: 1 });
mockCreditScoreUpsert.mockResolvedValue({});
mockCreditScoreHistoryUpsert.mockResolvedValue({});
mockPublishProjection.mockResolvedValue(undefined);
mockCreateNotification.mockResolvedValue(null);
});

// ── Fixture event page ────────────────────────────────────────────────────────
Expand Down
29 changes: 28 additions & 1 deletion backend/src/indexer/projections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
mockUserUpsert,
mockGoalUpsert,
mockGoalUpdateMany,
mockGoalFindUnique,
mockSubUpsert,
mockSubUpdateMany,
mockTipUpsert,
Expand All @@ -14,10 +15,12 @@ const {
mockCreditScoreUpsert,
mockCreditScoreHistoryUpsert,
mockPublishProjection,
mockCreateNotification,
} = vi.hoisted(() => ({
mockUserUpsert: vi.fn(),
mockGoalUpsert: vi.fn(),
mockGoalUpdateMany: vi.fn(),
mockGoalFindUnique: vi.fn(),
mockSubUpsert: vi.fn(),
mockSubUpdateMany: vi.fn(),
mockTipUpsert: vi.fn(),
Expand All @@ -26,12 +29,13 @@ const {
mockCreditScoreUpsert: vi.fn(),
mockCreditScoreHistoryUpsert: vi.fn(),
mockPublishProjection: vi.fn(),
mockCreateNotification: vi.fn(),
}));

vi.mock('../db/prisma.js', () => ({
prisma: {
user: { upsert: mockUserUpsert },
goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany },
goal: { upsert: mockGoalUpsert, updateMany: mockGoalUpdateMany, findUnique: mockGoalFindUnique },
subscription: { upsert: mockSubUpsert, updateMany: mockSubUpdateMany },
tip: { upsert: mockTipUpsert },
eventLog: { findFirst: mockEventLogFindFirst, create: mockEventLogCreate },
Expand All @@ -44,6 +48,10 @@ vi.mock('./realtime-publisher.js', () => ({
publishProjection: mockPublishProjection,
}));

vi.mock('../modules/notifications/notifications.service.js', () => ({
createNotification: mockCreateNotification,
}));

/** Build a decoded event; `value` is the positional payload tuple. */
const event = (topic: string, value: unknown, overrides: Partial<DecodedEvent> = {}): DecodedEvent => ({
ledger: 100,
Expand Down Expand Up @@ -87,6 +95,8 @@ beforeEach(() => {
mockEventLogCreate.mockResolvedValue({});
mockGoalUpsert.mockResolvedValue({});
mockGoalUpdateMany.mockResolvedValue({ count: 1 });
mockGoalFindUnique.mockResolvedValue(null);
mockCreateNotification.mockResolvedValue(null);
mockSubUpsert.mockResolvedValue({});
mockSubUpdateMany.mockResolvedValue({ count: 1 });
mockCreditScoreUpsert.mockResolvedValue({});
Expand Down Expand Up @@ -227,6 +237,23 @@ describe('projectEvent — goals (#899)', () => {
expect(mockGoalUpsert.mock.calls[0][0].update).toEqual(mockGoalUpsert.mock.calls[1][0].update);
});

it('notifies the creator when the goal transitions into COMPLETED (#964)', async () => {
mockGoalFindUnique.mockResolvedValue(null);
await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000']));
expect(mockCreateNotification).toHaveBeenCalledWith(
'u_' + ADDR_A,
'goal_reached',
expect.objectContaining({ targetStroops: '1000', raisedStroops: '1000' }),
);
});

it('does not re-notify when replaying an already-COMPLETED goal', async () => {
mockGoalFindUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ status: 'COMPLETED' });
await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000']));
await projectEvent(event('goal_reached', [ADDR_A, '1000', '1000']));
expect(mockCreateNotification).toHaveBeenCalledTimes(1);
});

it('cancels a goal via updateMany (no-op when absent)', async () => {
await projectEvent(event('goal_cancel', ADDR_A));
expect(mockGoalUpdateMany).toHaveBeenCalledWith({
Expand Down
Loading
Loading