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 container = getContainer('broadcasts');
|
||||||
const { resource } = await container.items.create(doc);
|
const { resource } = await container.items.create(doc);
|
||||||
if (!resource) throw new Error('Failed to create broadcast');
|
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> {
|
export async function getBroadcast(id: string, productId: string): Promise<Broadcast | null> {
|
||||||
const container = getContainer('broadcasts');
|
const container = getContainer('broadcasts');
|
||||||
try {
|
try {
|
||||||
const { resource } = await container.item(id, productId).read();
|
const { resource } = await container.item(id, productId).read();
|
||||||
return resource as Broadcast | null;
|
return resource as unknown as Broadcast | null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { code?: number }).code === 404) return null;
|
if ((err as { code?: number }).code === 404) return null;
|
||||||
throw err;
|
throw err;
|
||||||
@ -134,7 +134,7 @@ export async function createDelivery(doc: BroadcastDelivery): Promise<BroadcastD
|
|||||||
const container = getContainer('broadcast_deliveries');
|
const container = getContainer('broadcast_deliveries');
|
||||||
const { resource } = await container.items.create(doc);
|
const { resource } = await container.items.create(doc);
|
||||||
if (!resource) throw new Error('Failed to create delivery');
|
if (!resource) throw new Error('Failed to create delivery');
|
||||||
return resource as BroadcastDelivery;
|
return resource as unknown as BroadcastDelivery;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDelivery(
|
export async function getDelivery(
|
||||||
@ -145,7 +145,7 @@ export async function getDelivery(
|
|||||||
const id = `${broadcastId}:${userId}`;
|
const id = `${broadcastId}:${userId}`;
|
||||||
try {
|
try {
|
||||||
const { resource } = await container.item(id, userId).read();
|
const { resource } = await container.item(id, userId).read();
|
||||||
return resource as BroadcastDelivery | null;
|
return resource as unknown as BroadcastDelivery | null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { code?: number }).code === 404) return null;
|
if ((err as { code?: number }).code === 404) return null;
|
||||||
throw err;
|
throw err;
|
||||||
@ -219,7 +219,7 @@ export async function queueInAppMessage(doc: InAppMessage): Promise<InAppMessage
|
|||||||
const container = getContainer('in_app_messages');
|
const container = getContainer('in_app_messages');
|
||||||
const { resource } = await container.items.create(doc);
|
const { resource } = await container.items.create(doc);
|
||||||
if (!resource) throw new Error('Failed to queue in-app message');
|
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(
|
export async function getInAppMessagesForUser(
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
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 { getRequestProductId } from '../../lib/request-context.js';
|
||||||
import { CreateFeedbackSchema, UpdateFeedbackSchema, QueryFeedbackSchema } from './types.js';
|
import { CreateFeedbackSchema, UpdateFeedbackSchema, QueryFeedbackSchema } from './types.js';
|
||||||
|
import { generateSasUrl, BLOB_CONTAINERS } from '../../lib/blob.js';
|
||||||
import {
|
import {
|
||||||
createFeedback,
|
createFeedback,
|
||||||
getFeedback,
|
getFeedback,
|
||||||
@ -14,6 +15,8 @@ import {
|
|||||||
updateFeedback,
|
updateFeedback,
|
||||||
deleteFeedback,
|
deleteFeedback,
|
||||||
getFeedbackStats,
|
getFeedbackStats,
|
||||||
|
generateScreenshotBlobPath,
|
||||||
|
hasScreenshot,
|
||||||
} from './repository.js';
|
} from './repository.js';
|
||||||
|
|
||||||
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
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);
|
const productId = getRequestProductId(req);
|
||||||
return getFeedbackStats(productId);
|
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