feat(feedback): Phase 1.3 - add SAS endpoint and screenshot view/delete routes
This commit is contained in:
parent
b5fb2b683b
commit
cfbaa92539
@ -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(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user