Skip to content
50 changes: 49 additions & 1 deletion src/controllers/merchant.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
getMerchant,
listMerchants,
registerMerchant,
getMyProfile,
updateMyProfile,
} from '../services/merchant.services.js';
import { validateRegisterMerchant } from '../utils/validation.js';
import { validateRegisterMerchant, validateUpdateMerchant } from '../utils/validation.js';
import { AppError } from '../utils/errors.js';

export const createMerchantController = async (req: Request, res: Response) => {
Expand Down Expand Up @@ -60,3 +62,49 @@ export const registerMerchantController = async (req: Request, res: Response): P
res.status(500).json({ error: 'Internal Server Error' });
}
};

export const getMyProfileController = async (req: Request, res: Response): Promise<void> => {
const merchant = req.merchant;

if (!merchant) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

try {
const profile = await getMyProfile(merchant.id);
res.status(200).json(profile);
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Internal Server Error' });
}
};

export const updateMyProfileController = async (req: Request, res: Response): Promise<void> => {
const merchant = req.merchant;

if (!merchant) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

const errors = validateUpdateMerchant(req.body);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: 'Validation failed', errors });
return;
}

try {
const profile = await updateMyProfile(merchant.id, req.body);
res.status(200).json(profile);
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Internal Server Error' });
}
};
4 changes: 4 additions & 0 deletions src/routes/merchant.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
getMerchantController,
listMerchantsController,
registerMerchantController,
getMyProfileController,
updateMyProfileController,
} from '../controllers/merchant.controllers.js';
import {
createApiKeyController,
Expand All @@ -15,6 +17,8 @@ import { authenticateMerchant, authenticateSessionOnly } from '../middlewares/au
const router = Router();

router.post('/register', authenticateMerchant, registerMerchantController);
router.get('/me', authenticateMerchant, getMyProfileController);
router.patch('/me', authenticateMerchant, updateMyProfileController);
router.post('/api-keys', authenticateSessionOnly, createApiKeyController);
router.get('/api-keys', authenticateSessionOnly, listApiKeysController);
router.delete('/api-keys/:id', authenticateSessionOnly, revokeApiKeyController);
Expand Down
52 changes: 50 additions & 2 deletions src/services/merchant.services.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Merchant } from '@prisma/client';
import { Merchant, Prisma } from '@prisma/client';
import prisma from '../config/prisma.js';
import { AppError } from '../utils/errors.js';
import { RegisterMerchantInput } from '../utils/validation.js';
import { RegisterMerchantInput, UpdateMerchantInput } from '../utils/validation.js';
import { generateOtp, hashOtp } from './otp.services.js';
import { sendOtp } from './email.service.js';

Expand All @@ -24,12 +24,14 @@ export const sanitizeMerchant = (merchant: Merchant) => ({
merchantId: merchant.merchantId,
email: merchant.email,
address: merchant.address,
account: merchant.account,
firstName: merchant.firstName,
lastName: merchant.lastName,
businessName: merchant.businessName,
category: merchant.category,
description: merchant.description,
logo: merchant.logo,
webhook: merchant.webhook,
active: merchant.active,
verified: merchant.verified,
emailVerified: merchant.emailVerified,
Expand Down Expand Up @@ -136,3 +138,49 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan

return sanitizeMerchant(updatedMerchant);
};

/**
* Returns the authenticated merchant's own profile.
*/
export const getMyProfile = async (id: string) => {
const merchant = await prisma.merchant.findUnique({ where: { id } });

if (!merchant) {
throw new AppError(404, 'Merchant not found');
}

return sanitizeMerchant(merchant);
};

/**
* Partially updates the authenticated merchant's editable profile fields.
*
* Only fields present in `data` are written. Strings are trimmed; an empty
* `logo`/`webhook` is normalized to null so the merchant can clear them.
* Non-editable fields are never read here, so they cannot be changed.
*/
export const updateMyProfile = async (id: string, data: UpdateMerchantInput) => {
const updateData: Prisma.MerchantUpdateInput = {};

const textFields = ['firstName', 'lastName', 'businessName', 'category', 'description'] as const;
for (const field of textFields) {
const value = data[field];
if (value !== undefined) {
updateData[field] = value.trim();
}
}

if (data.logo !== undefined) {
const logo = typeof data.logo === 'string' ? data.logo.trim() : data.logo;
updateData.logo = logo ? logo : null;
}

if (data.webhook !== undefined) {
const webhook = typeof data.webhook === 'string' ? data.webhook.trim() : data.webhook;
updateData.webhook = webhook ? webhook : null;
}

const updated = await prisma.merchant.update({ where: { id }, data: updateData });

return sanitizeMerchant(updated);
};
67 changes: 67 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,70 @@ export const validateRegisterMerchant = (body: unknown): ValidationErrors => {

return errors;
};

export interface UpdateMerchantInput {
firstName?: string;
lastName?: string;
businessName?: string;
category?: string;
description?: string;
logo?: string | null;
webhook?: string | null;
}

const EDITABLE_MERCHANT_FIELDS = [
'firstName',
'lastName',
'businessName',
'category',
'description',
'logo',
'webhook',
] as const;

const UPDATE_REQUIRED_TEXT_FIELDS = [
'firstName',
'lastName',
'businessName',
'category',
'description',
] as const;

const isValidHttpsUrl = (value: string): boolean => {
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
};

export const validateUpdateMerchant = (body: unknown): ValidationErrors => {
const errors: ValidationErrors = {};
const payload = (body ?? {}) as Record<string, unknown>;

const present = EDITABLE_MERCHANT_FIELDS.filter(field => payload[field] !== undefined);
if (present.length === 0) {
errors._empty = 'At least one valid field is required';
return errors;
}

for (const field of UPDATE_REQUIRED_TEXT_FIELDS) {
if (payload[field] !== undefined && !isNonEmptyString(payload[field])) {
errors[field] = `${field} must be a non-empty string`;
}
}

if (payload.logo !== undefined && payload.logo !== null && typeof payload.logo !== 'string') {
errors.logo = 'logo must be a string or null';
}

if (payload.webhook !== undefined && payload.webhook !== null) {
if (typeof payload.webhook !== 'string') {
errors.webhook = 'webhook must be a string or null';
} else if (payload.webhook.trim().length > 0 && !isValidHttpsUrl(payload.webhook.trim())) {
errors.webhook = 'webhook must be a valid HTTPS URL';
}
}

return errors;
};
161 changes: 161 additions & 0 deletions tests/integration/merchant.profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { jest } from '@jest/globals';
import { mockReset } from 'jest-mock-extended';
import request from 'supertest';

const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any;
const { default: app } = await import('../../src/app.js');

const ME_URL = '/api/v1/merchants/me';

const baseMerchant = {
id: 'uuid-1',
merchantId: 1,
address: '0x123',
account: 'CCONTRACT',
email: 'ada@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
businessName: 'Analytical Engines',
category: 'software',
description: 'We build computing machines.',
logo: null,
webhook: null,
active: true,
verified: false,
emailVerified: false,
registered: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// Internal relations that the sanitizer allow-list must strip from responses.
refreshTokens: [{ id: 'rt-1', token: 'secret-token' }],
apiKeys: [{ id: 'ak-1', keyHash: 'hashed-secret' }],
};

const authenticateAs = (merchant: Record<string, unknown>) => {
prismaMock.refreshToken.findUnique.mockResolvedValue({
id: 'session-1',
merchantId: merchant.id,
token: 'valid-token',
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
createdAt: new Date(),
merchant,
} as any);
};

describe('GET /api/v1/merchants/me', () => {
beforeEach(() => mockReset(prismaMock));

test('returns 401 when unauthenticated', async () => {
const response = await request(app).get(ME_URL);
expect(response.status).toBe(401);
});

test('returns 200 with the full profile and no internal fields', async () => {
authenticateAs(baseMerchant);
prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant as any);

const response = await request(app).get(ME_URL).set('Authorization', 'Bearer valid-token');

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: 'uuid-1', account: 'CCONTRACT', webhook: null });
expect(response.body).not.toHaveProperty('refreshTokens');
expect(response.body).not.toHaveProperty('apiKeys');
});
});

describe('PATCH /api/v1/merchants/me', () => {
beforeEach(() => mockReset(prismaMock));

test('returns 401 when unauthenticated', async () => {
const response = await request(app).patch(ME_URL).send({ firstName: 'Grace' });
expect(response.status).toBe(401);
expect(prismaMock.merchant.update).not.toHaveBeenCalled();
});

test('updates a valid partial payload and returns 200', async () => {
authenticateAs(baseMerchant);
prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data }));

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({ firstName: 'Grace', webhook: 'https://example.com/hook' });

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ firstName: 'Grace', webhook: 'https://example.com/hook' });
});

test('silently ignores non-editable fields (address/email/merchantId/account)', async () => {
authenticateAs(baseMerchant);
prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data }));

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({
firstName: 'Grace',
address: '0xHACK',
email: 'evil@example.com',
merchantId: 999,
account: '0xHACKED',
});

expect(response.status).toBe(200);
const updateArg = prismaMock.merchant.update.mock.calls[0][0];
expect(updateArg.data).toEqual({ firstName: 'Grace' });
expect(response.body.address).toBe('0x123');
expect(response.body.email).toBe('ada@example.com');
expect(response.body.merchantId).toBe(1);
expect(response.body.account).toBe('CCONTRACT');
});

test('returns 400 for an invalid (non-HTTPS) webhook', async () => {
authenticateAs(baseMerchant);

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({ webhook: 'http://example.com/hook' });

expect(response.status).toBe(400);
expect(response.body.error).toBe('Validation failed');
expect(prismaMock.merchant.update).not.toHaveBeenCalled();
});

test('clears the webhook when sent null', async () => {
authenticateAs(baseMerchant);
prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data }));

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({ webhook: null });

expect(response.status).toBe(200);
expect(response.body.webhook).toBeNull();
});

test('returns 400 for a required text field sent empty', async () => {
authenticateAs(baseMerchant);

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({ firstName: '' });

expect(response.status).toBe(400);
expect(prismaMock.merchant.update).not.toHaveBeenCalled();
});

test('returns 400 for an empty payload', async () => {
authenticateAs(baseMerchant);

const response = await request(app)
.patch(ME_URL)
.set('Authorization', 'Bearer valid-token')
.send({});

expect(response.status).toBe(400);
expect(prismaMock.merchant.update).not.toHaveBeenCalled();
});
});
Loading
Loading