feat(feedback): Phase 1.3 - add SAS endpoint and screenshot view/delete routes

This commit is contained in:
saravanakumardb1 2026-03-02 23:55:04 -08:00
parent b5fb2b683b
commit cfbaa92539
2 changed files with 95 additions and 6 deletions

View File

@ -21,14 +21,14 @@ export async function createBroadcast(doc: Broadcast): Promise<Broadcast> {
const container = getContainer('broadcasts');
const { resource } = await container.items.create(doc);
if (!resource) throw new Error('Failed to create broadcast');
return resource as Broadcast;
return resource as unknown as Broadcast;
}
export async function getBroadcast(id: string, productId: string): Promise<Broadcast | null> {
const container = getContainer('broadcasts');
try {
const { resource } = await container.item(id, productId).read();
return resource as Broadcast | null;
return resource as unknown as Broadcast | null;
} catch (err) {
if ((err as { code?: number }).code === 404) return null;
throw err;
@ -134,7 +134,7 @@ export async function createDelivery(doc: BroadcastDelivery): Promise<BroadcastD
const container = getContainer('broadcast_deliveries');
const { resource } = await container.items.create(doc);
if (!resource) throw new Error('Failed to create delivery');
return resource as BroadcastDelivery;
return resource as unknown as BroadcastDelivery;
}
export async function getDelivery(
@ -145,7 +145,7 @@ export async function getDelivery(
const id = `${broadcastId}:${userId}`;
try {
const { resource } = await container.item(id, userId).read();
return resource as BroadcastDelivery | null;
return resource as unknown as BroadcastDelivery | null;
} catch (err) {
if ((err as { code?: number }).code === 404) return null;
throw err;
@ -219,7 +219,7 @@ export async function queueInAppMessage(doc: InAppMessage): Promise<InAppMessage
const container = getContainer('in_app_messages');
const { resource } = await container.items.create(doc);
if (!resource) throw new Error('Failed to queue in-app message');
return resource as InAppMessage;
return resource as unknown as InAppMessage;
}
export async function getInAppMessagesForUser(

View File

@ -4,9 +4,10 @@
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { UnauthorizedError, ForbiddenError, NotFoundError, BadRequestError } from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import { CreateFeedbackSchema, UpdateFeedbackSchema, QueryFeedbackSchema } from './types.js';
import { generateSasUrl, BLOB_CONTAINERS } from '../../lib/blob.js';
import {
createFeedback,
getFeedback,
@ -14,6 +15,8 @@ import {
updateFeedback,
deleteFeedback,
getFeedbackStats,
generateScreenshotBlobPath,
hasScreenshot,
} from './repository.js';
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
@ -79,4 +82,90 @@ export async function feedbackRoutes(app: FastifyInstance): Promise<void> {
const productId = getRequestProductId(req);
return getFeedbackStats(productId);
});
// ── User: Generate SAS URL for screenshot upload (Phase 1.3) ─────────────
app.post('/feedback/sas', async req => {
const userId = requireAuth(req);
const productId = getRequestProductId(req);
// Validate content type
const body = req.body as { contentType?: 'image/png' | 'image/jpeg' | 'image/webp' };
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!body.contentType || !validTypes.includes(body.contentType)) {
throw new BadRequestError('Invalid contentType. Must be image/png, image/jpeg, or image/webp');
}
// Generate blob path (feedbackId will be assigned after creation)
// For pre-upload, we use a temp path that gets moved on feedback submission
const tempFeedbackId = `temp_${userId}_${Date.now()}`;
const blobPath = generateScreenshotBlobPath(productId, tempFeedbackId, body.contentType);
// Generate SAS URL for upload (5 minutes expiry)
const uploadUrl = await generateSasUrl(
BLOB_CONTAINERS.feedbackScreenshots,
blobPath,
'w', // write permission
5 // 5 minutes
);
return {
blobPath,
uploadUrl,
expiresIn: 300, // 5 minutes
maxSizeBytes: 5 * 1024 * 1024, // 5MB limit
};
});
// ── Admin: Get screenshot view URL (Phase 1.3) ────────────────────────────
app.get<{ Params: { id: string } }>('/feedback/:id/screenshot', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const fb = await getFeedback(req.params.id, productId);
if (!fb) throw new NotFoundError('Feedback not found');
if (!hasScreenshot(fb)) {
throw new NotFoundError('No screenshot attached to this feedback');
}
// Generate fresh SAS URL for viewing (15 minutes expiry)
const viewUrl = await generateSasUrl(
BLOB_CONTAINERS.feedbackScreenshots,
fb.screenshotBlobPath!,
'r', // read permission
15 // 15 minutes
);
return {
url: viewUrl,
contentType: fb.screenshotContentType,
sizeBytes: fb.screenshotSizeBytes,
expiresIn: 900, // 15 minutes
};
});
// ── Admin: Delete screenshot (GDPR/privacy compliance) (Phase 1.3) ───────
app.delete<{ Params: { id: string } }>('/feedback/:id/screenshot', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const fb = await getFeedback(req.params.id, productId);
if (!fb) throw new NotFoundError('Feedback not found');
if (!hasScreenshot(fb)) {
throw new NotFoundError('No screenshot attached to this feedback');
}
// Update feedback to remove screenshot reference
const updated = await updateFeedback(req.params.id, productId, {
screenshotBlobPath: undefined,
screenshotUrl: undefined,
screenshotUrlExpiresAt: undefined,
screenshotContentType: undefined,
screenshotSizeBytes: undefined,
} as unknown as import('./types.js').UpdateFeedbackInput);
// Note: Actual blob deletion would be handled by lifecycle policy or batch job
// to avoid blocking the request
reply.status(204);
});
}