Skip to content
Open
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
40 changes: 39 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
restoreCampaign,
softDeleteCampaign,
SortOrder,
updateCampaign,
} from './services/campaignStore';
import { checkDbHealth } from './services/db';
import { getCampaignTimeline, listCampaignHistory } from './services/eventHistory';
Expand Down Expand Up @@ -72,6 +73,7 @@ import {
parseTimelineQuery,
reconcilePledgePayloadSchema,
refundPayloadSchema,
updateMetadataPayloadSchema,
zodIssuesToErrorMessage,
zodIssuesToValidationIssues,
parseCampaignListQuery,
Expand Down Expand Up @@ -631,6 +633,42 @@ app.post(
},
);

app.patch(
'/api/campaigns/:id/metadata',
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
validateBody(updateMetadataPayloadSchema),
async (req: Request, res: Response, next: express.NextFunction) => {
try {
const parsedId = parseCampaignId(req.params.id);
if (!parsedId.ok) {
sendValidationError(parsedId.issues);
}

const campaign = getCampaign(parsedId.value);
if (!campaign) {
throw new AppError('Campaign not found.', 404, 'NOT_FOUND');
}

const body = req.body as z.infer<typeof updateMetadataPayloadSchema>;

// Merge incoming metadata over existing so omitted keys are preserved
const mergedMetadata =
body.metadata !== undefined
? { ...campaign.metadata, ...body.metadata }
: campaign.metadata;

const updated = updateCampaign(parsedId.value, {
...body,
metadata: mergedMetadata,
});
await invalidateCampaignCache();
res.json({ data: { ...updated, progress: calculateProgress(updated) } });
} catch (error) {
next(error);
}
},
);

app.post(
'/api/campaigns/:id/pledges',
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
Expand Down Expand Up @@ -904,7 +942,7 @@ app.get('/api/leaderboard', (req: Request, res: Response) => {
}
});

// ── Notification Routes ───────────────────────────────────────────────────────
// ΓöÇΓöÇ Notification Routes ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ

app.get('/api/notifications', (req: Request, res: Response) => {
const wallet = normalizeQueryValue(req.query.wallet);
Expand Down
88 changes: 1 addition & 87 deletions backend/src/middleware/validateBody.test.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1 @@
import express from 'express';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';

import { validateBody } from './validateBody';

const schema = z
.object({
name: z.string(),
age: z.number().int().nonnegative(),
})
.strict();

function buildApp() {
const app = express();
app.use(express.json());
app.post('/echo', validateBody(schema), (req, res) => {
res.json({ data: req.body });
});
return app;
}

describe('validateBody', () => {
it('passes through and exposes parsed.data when the payload is valid', async () => {
const response = await request(buildApp()).post('/echo').send({ name: 'goal', age: 1 });

expect(response.status).toBe(200);
expect(response.body).toEqual({ data: { name: 'goal', age: 1 } });
});

it('replaces req.body with parsed.data so coercion and stripping are visible to downstream handlers', async () => {
// Schema coerces a string into a number and transforms the name to upper
// case. The downstream handler reads req.body, so if the middleware
// stopped assigning req.body = parsed.data the response would echo back
// the raw input instead of the parsed form.
const coercingSchema = z.object({
name: z.string().transform((value) => value.toUpperCase()),
age: z.coerce.number().int(),
});

const app = express();
app.use(express.json());
app.post('/coerce', validateBody(coercingSchema), (req, res) => {
res.json({ data: req.body });
});

const response = await request(app).post('/coerce').send({ name: 'goal', age: '7' });

expect(response.status).toBe(200);
expect(response.body).toEqual({ data: { name: 'GOAL', age: 7 } });
});

it('returns 400 with the Validation failed shape when a required field is missing', async () => {
const response = await request(buildApp()).post('/echo').send({ age: 1 });

expect(response.status).toBe(400);
expect(response.body.error).toBe('Validation failed');
expect(Array.isArray(response.body.details)).toBe(true);
expect(response.body.details).toHaveLength(1);
expect(response.body.details[0].path).toEqual(['name']);
});

it('returns 400 with the Validation failed shape when a field has the wrong type', async () => {
const response = await request(buildApp()).post('/echo').send({ name: 'goal', age: 'one' });

expect(response.status).toBe(400);
expect(response.body.error).toBe('Validation failed');
expect(response.body.details).toHaveLength(1);
expect(response.body.details[0].path).toEqual(['age']);
expect(response.body.details[0].code).toBe('invalid_type');
});

it('returns 400 with the Validation failed shape when an extra field is present on a strict schema', async () => {
const response = await request(buildApp())
.post('/echo')
.send({ name: 'goal', age: 1, surprise: true });

expect(response.status).toBe(400);
expect(response.body.error).toBe('Validation failed');
expect(response.body.details.length).toBeGreaterThanOrEqual(1);
const unrecognized = response.body.details.find(
(issue: { code: string }) => issue.code === 'unrecognized_keys',
);
expect(unrecognized).toBeDefined();
});
});
浩潰瑲攠灸敲獳映潲攧灸敲獳㬧਍浩潰瑲爠煥敵瑳映潲猧灵牥整瑳㬧਍浩潰瑲笠搠獥牣扩ⱥ攠灸捥ⱴ椠⁴⁽牦浯✠楶整瑳㬧਍浩潰瑲笠稠素映潲稧摯㬧਍਍浩潰瑲笠瘠污摩瑡䉥摯⁹⁽牦浯✠⼮慶楬慤整潂祤㬧਍਍潣獮⁴捳敨慭㴠稠਍†漮橢捥⡴ൻ †渠浡㩥稠献牴湩⡧Ⱙ਍††条㩥稠渮浵敢⡲⸩湩⡴⸩潮湮来瑡癩⡥Ⱙ਍†⥽਍†献牴捩⡴㬩਍਍畦据楴湯戠極摬灁⡰
ൻ 挠湯瑳愠灰㴠攠灸敲獳⤨഻ 愠灰甮敳攨灸敲獳樮潳⡮⤩഻ 愠灰瀮獯⡴⼧捥潨Ⱗ瘠污摩瑡䉥摯⡹捳敨慭Ⱙ⠠敲ⱱ爠獥
㸽笠਍††敲⹳獪湯笨搠瑡㩡爠煥戮摯⁹⥽഻ 素㬩਍†敲畴湲愠灰഻紊਍਍敤捳楲敢✨慶楬慤整潂祤Ⱗ⠠
㸽笠਍†瑩✨慰獳獥琠牨畯桧愠摮攠灸獯獥瀠牡敳⹤慤慴眠敨桴⁥慰汹慯⁤獩瘠污摩Ⱗ愠祳据⠠
㸽笠਍††潣獮⁴敲灳湯敳㴠愠慷瑩爠煥敵瑳戨極摬灁⡰⤩瀮獯⡴⼧捥潨⤧献湥⡤⁻慮敭›朧慯❬‬条㩥ㄠ素㬩਍਍††硥数瑣爨獥潰獮⹥瑳瑡獵⸩潴敂㈨〰㬩਍††硥数瑣爨獥潰獮⹥潢祤⸩潴煅慵⡬⁻慤慴›⁻慮敭›朧慯❬‬条㩥ㄠ素素㬩਍†⥽഻ഊ 椠⡴爧灥慬散⁳敲⹱潢祤眠瑩⁨慰獲摥搮瑡⁡潳挠敯捲潩湡⁤瑳楲灰湩⁧牡⁥楶楳汢⁥潴搠睯獮牴慥慨摮敬獲Ⱗ愠祳据⠠
㸽笠਍††⼯匠档浥⁡潣牥散⁳⁡瑳楲杮椠瑮⁡畮扭牥愠摮琠慲獮潦浲⁳桴⁥慮敭琠灵数൲ †⼠ 慣敳‮桔⁥潤湷瑳敲浡栠湡汤牥爠慥獤爠煥戮摯ⱹ猠晩琠敨洠摩汤睥牡൥ †⼠ 瑳灯数⁤獡楳湧湩⁧敲⹱潢祤㴠瀠牡敳⹤慤慴琠敨爠獥潰獮⁥潷汵⁤捥潨戠捡൫ †⼠ 桴⁥慲⁷湩異⁴湩瑳慥⁤景琠敨瀠牡敳⁤潦浲മ †挠湯瑳挠敯捲湩卧档浥⁡‽⹺扯敪瑣笨਍†††慮敭›⹺瑳楲杮⤨琮慲獮潦浲⠨慶畬⥥㴠‾慶畬⹥潴灕数䍲獡⡥⤩ബ ††愠敧›⹺潣牥散渮浵敢⡲⸩湩⡴Ⱙ਍††⥽഻ഊ †挠湯瑳愠灰㴠攠灸敲獳⤨഻ †愠灰甮敳攨灸敲獳樮潳⡮⤩഻ †愠灰瀮獯⡴⼧潣牥散Ⱗ瘠污摩瑡䉥摯⡹潣牥楣杮捓敨慭Ⱙ⠠敲ⱱ爠獥
㸽笠਍†††敲⹳獪湯笨搠瑡㩡爠煥戮摯⁹⥽഻ †素㬩਍਍††潣獮⁴敲灳湯敳㴠愠慷瑩爠煥敵瑳愨灰⸩潰瑳✨振敯捲❥⸩敳摮笨渠浡㩥✠潧污Ⱗ愠敧›㜧‧⥽഻ഊ †攠灸捥⡴敲灳湯敳献慴畴⥳琮䉯⡥〲⤰഻ †攠灸捥⡴敲灳湯敳戮摯⥹琮䕯畱污笨搠瑡㩡笠渠浡㩥✠佇䱁Ⱗ愠敧›‷⁽⥽഻ 素㬩਍਍†瑩✨敲畴湲⁳〴‰楷桴琠敨嘠污摩瑡潩慦汩摥猠慨数眠敨⁡敲畱物摥映敩摬椠⁳業獳湩❧‬獡湹⁣⤨㴠‾ൻ †挠湯瑳爠獥潰獮⁥‽睡楡⁴敲畱獥⡴畢汩䅤灰⤨⸩潰瑳✨支档❯⸩敳摮笨愠敧›‱⥽഻ഊ †攠灸捥⡴敲灳湯敳献慴畴⥳琮䉯⡥〴⤰഻ †攠灸捥⡴敲灳湯敳戮摯⹹牥潲⥲琮䉯⡥嘧污摩瑡潩慦汩摥⤧഻ †攠灸捥⡴牁慲⹹獩牁慲⡹敲灳湯敳戮摯⹹敤慴汩⥳⸩潴敂琨畲⥥഻ †攠灸捥⡴敲灳湯敳戮摯⹹敤慴汩⥳琮䡯癡䱥湥瑧⡨⤱഻ †攠灸捥⡴敲灳湯敳戮摯⹹敤慴汩孳崰瀮瑡⥨琮䕯畱污嬨渧浡❥⥝഻ 素㬩਍਍†瑩✨敲畴湲⁳〴‰楷桴琠敨嘠污摩瑡潩慦汩摥猠慨数眠敨⁡楦汥⁤慨⁳桴⁥牷湯⁧祴数Ⱗ愠祳据⠠
㸽笠਍††潣獮⁴敲灳湯敳㴠愠慷瑩爠煥敵瑳戨極摬灁⡰⤩瀮獯⡴⼧捥潨⤧献湥⡤⁻慮敭›朧慯❬‬条㩥✠湯❥素㬩਍਍††硥数瑣爨獥潰獮⹥瑳瑡獵⸩潴敂㐨〰㬩਍††硥数瑣爨獥潰獮⹥潢祤攮牲牯⸩潴敂✨慖楬慤楴湯映楡敬❤㬩਍††硥数瑣爨獥潰獮⹥潢祤搮瑥楡獬⸩潴慈敶敌杮桴ㄨ㬩਍††硥数瑣爨獥潰獮⹥潢祤搮瑥楡獬せ⹝慰桴⸩潴煅慵⡬❛条❥⥝഻ †攠灸捥⡴敲灳湯敳戮摯⹹敤慴汩孳崰挮摯⥥琮䉯⡥椧癮污摩瑟灹❥㬩਍†⥽഻ഊ 椠⡴爧瑥牵獮㐠〰眠瑩⁨桴⁥慖楬慤楴湯映楡敬⁤桳灡⁥桷湥愠硥牴⁡楦汥⁤獩瀠敲敳瑮漠⁡瑳楲瑣猠档浥❡‬獡湹⁣⤨㴠‾ൻ †挠湯瑳爠獥潰獮⁥‽睡楡⁴敲畱獥⡴畢汩䅤灰⤨ഩ ††⸠潰瑳✨支档❯ഩ ††⸠敳摮笨渠浡㩥✠潧污Ⱗ愠敧›ⰱ猠牵牰獩㩥琠畲⁥⥽഻ഊ †攠灸捥⡴敲灳湯敳献慴畴⥳琮䉯⡥〴⤰഻ †攠灸捥⡴敲灳湯敳戮摯⹹牥潲⥲琮䉯⡥嘧污摩瑡潩慦汩摥⤧഻ †攠灸捥⡴敲灳湯敳戮摯⹹敤慴汩⹳敬杮桴⸩潴敂片慥整呲慨佮䕲畱污ㄨ㬩਍††潣獮⁴湵敲潣湧穩摥㴠爠獥潰獮⹥潢祤搮瑥楡獬昮湩⡤਍†††椨獳敵›⁻潣敤›瑳楲杮素
㸽椠獳敵挮摯⁥㴽‽甧牮捥杯楮敺彤敫獹Ⱗ਍††㬩਍††硥数瑣用牮捥杯楮敺⥤琮䉯䑥晥湩摥⤨഻ 素㬩਍⥽഻

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Invalid character.
6 changes: 3 additions & 3 deletions backend/src/middleware/validateBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { ZodError, ZodIssue, ZodType } from 'zod';

/**
* Shape of a 400 response emitted by the validation middleware. The
* Shape of a 422 response emitted by the validation middleware. The
* `details` array is the raw `ZodIssue[]` so callers see the exact path,
* code, and message for every offending field without the middleware
* needing to map them into a project-specific shape.
Expand All @@ -17,7 +17,7 @@ export interface ValidationErrorResponse {
* Zod schema. On success, `req.body` is replaced with the parsed (and
* potentially transformed or stripped) value so downstream handlers see
* the validated shape. On failure, the middleware short-circuits with a
* 400 response of the form `{ error: 'Validation failed', details: [...] }`.
* 422 response of the form `{ error: 'Validation failed', details: [...] }`.
*
* Uses `safeParseAsync` so the middleware works with schemas that include
* async refinements or transforms; plain synchronous schemas resolve in a
Expand All @@ -36,7 +36,7 @@ export function validateBody<TSchema extends ZodType>(schema: TSchema): RequestH
error: 'Validation failed',
details: (parsed.error as ZodError).issues,
};
res.status(400).json(body);
res.status(422).json(body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Remaining 400 validation statuses, assertions, and documented response codes.
rg -n --type=ts -C3 'VALIDATION_ERROR' backend/src
rg -n --type=ts -C2 'toBe\(400\)|status\(400\)|"400"|\x27400\x27' backend/src
fd -e yaml -e yml -e json --iglob '*openapi*' --iglob '*swagger*' -x rg -n -C3 '422|400' {}

Repository: ritik4ever/stellar-goal-vault

Length of output: 9356


🏁 Script executed:

#!/bin/bash
set -e

echo "== candidate files =="
git ls-files 'backend/src/**' | sed -n '1,200p'

echo "== validateBody.ts outline =="
ast-grep outline backend/src/middleware/validateBody.ts || true
echo "== validateBody.ts =="
cat -n backend/src/middleware/validateBody.ts | sed -n '1,140p'

echo "== index.ts validation handlers outline =="
ast-grep outline backend/src/index.ts --match sendValidationError || true
echo "== index.ts validation helper/envelope =="
cat -n backend/src/index.ts | sed -n '140,220p'

echo "== route usages around metadata/validations =="
rg -n --type=ts -C4 'sendValidationError|validateBody|parseCampaignListQuery|validateCampaignMetadata|metadata|campaigns/:id' backend/src/index.ts backend/src/api.test.ts backend/src/historyEndpoint.test.ts

echo "== openapi response codes =="
fd -e yaml -e yml -e json --glob '*openapi*' --glob '*swagger*' . | xargs -r rg -n -C3 '422|400|string\(\)|VALIDATION_ERROR' || true

Repository: ritik4ever/stellar-goal-vault

Length of output: 19441


🏁 Script executed:

#!/bin/bash
set -e

echo "== validateBody middleware test relevant sections =="
cat -n backend/src/middleware/validateBody.test.ts | sed -n '1,180p'

echo "== AppError / error envelope definitions and handlers =="
cat -n backend/src/types/errors.ts | sed -n '1,220p'
rg -n --type=ts -C3 'class AppError|function zodIssuesToValidationIssues|function zodIssuesToErrorMessage|sendResponseForError|sendAppError|validationIssueSchema|apiErrorSchema' backend/src/types errors.ts backend/src validation schemas.ts backend/src/openapi.ts backend/src/index.ts | sed -n '1,220p'

echo "== openapi generation status/response docs =="
cat -n backend/src/openapi.ts | sed -n '1,180p'

echo "== route tests for metadata id query validation statuses =="
cat -n backend/src/api.test.ts | sed -n '320,370p'
rg -n --type=ts -C3 "metadata|invalid .*id|campaignIdSchema|page invalid|limit 999|asset INVALID" backend/src/*.test.ts backend/src/api.test.ts

Repository: ritik4ever/stellar-goal-vault

Length of output: 21984


Align validation errors to a single status/payload contract.

validateBody now returns 422 for invalid request bodies, but sendValidationError still returns 400 for invalid campaign IDs and list query parameters on the same routes. Use one shared validation error status and payload shape across body, path, and query validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/middleware/validateBody.ts` at line 39, Update validateBody and
sendValidationError to use the same validation error status and payload shape
for invalid bodies, campaign IDs, and list query parameters. Reuse the existing
shared contract rather than maintaining separate 422 and 400 responses across
these validation paths.

return;
}

Expand Down
Loading
Loading