Skip to content
Merged
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
10 changes: 8 additions & 2 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import healthRoutes from './routes/healthRoutes.js';
import assessmentRoutes from './routes/assessmentRoutes.js';
import complianceRoutes from './routes/complianceRoutes.js';
import questionnaireRoutes from './routes/questionnaireRoutes.js';
import questionnairePublicRoutes from './routes/questionnairePublicRoutes.js';
import organizationRoutes from './routes/organizationRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
import { handleStripeWebhook } from './controllers/billingController.js';
Expand Down Expand Up @@ -239,9 +240,14 @@ app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/organizations', organizationRoutes);
app.use('/api/v1/billing', billingRoutes);

// Unguarded: public vendor questionnaire form (token-gated, not workspace auth).
// Mounted ahead of requireActivePlan so a visitor's own session cookie (e.g. the
// workspace owner previewing their own link from a paused-plan org) can't 402
// a route a vendor with no Retrieva account must be able to reach.
app.use('/api/v1/questionnaires', questionnairePublicRoutes);

// Paid routes — optionalAuth sets req.user when a token is present so the
// plan guard can check it; public sub-routes (e.g. questionnaire respond)
// have no token and pass through to the router's own authenticate.
// plan guard can check it.
// authenticate is idempotent so the router's router.use(authenticate) is a
// no-op when req.user is already set by optionalAuth.
// B2: setTenantContext (after auth) makes the active workspace available to the
Expand Down
34 changes: 34 additions & 0 deletions backend/routes/questionnairePublicRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Router } from 'express';
import { getPublicForm, submitResponse } from '../controllers/questionnaireController.js';
import { validateBody, validateParams } from '../middleware/validate.js';
import { submitQuestionnaireResponseSchema, tokenParamsSchema } from '../validators/schemas.js';

const router = Router();

// ---------------------------------------------------------------------------
// Public routes — no authentication, no plan gate (token-based access only).
// Mounted unguarded in app.js so a visitor's own session cookie (e.g. the
// workspace owner previewing their own link) can't trigger requireActivePlan
// for a route a vendor with no Retrieva account must be able to reach.
// ---------------------------------------------------------------------------

/**
* @route GET /api/v1/questionnaires/respond/:token
* @desc Load the public vendor questionnaire form
* @access Public (token-gated)
*/
router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm);

/**
* @route POST /api/v1/questionnaires/respond/:token
* @desc Save partial or final vendor response
* @access Public (token-gated)
*/
router.post(
'/respond/:token',
validateParams(tokenParamsSchema),
validateBody(submitQuestionnaireResponseSchema),
submitResponse
);

export default router;
29 changes: 2 additions & 27 deletions backend/routes/questionnaireRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,23 @@ import {
getQuestionnaire,
deleteQuestionnaire,
sendQuestionnaire,
getPublicForm,
submitResponse,
} from '../controllers/questionnaireController.js';
import { authenticate } from '../middleware/auth.js';
import { requireWorkspaceAccess } from '../middleware/workspaceAuth.js';
import { validateBody, validateParams, validateQuery } from '../middleware/validate.js';
import {
createQuestionnaireSchema,
sendQuestionnaireSchema,
submitQuestionnaireResponseSchema,
idParamsSchema,
tokenParamsSchema,
listQuestionnairesQuerySchema,
} from '../validators/schemas.js';

const router = Router();

// ---------------------------------------------------------------------------
// Public routes — no authentication required (token-based access only)
// ---------------------------------------------------------------------------

/**
* @route GET /api/v1/questionnaires/respond/:token
* @desc Load the public vendor questionnaire form
* @access Public (token-gated)
*/
router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm);

/**
* @route POST /api/v1/questionnaires/respond/:token
* @desc Save partial or final vendor response
* @access Public (token-gated)
*/
router.post(
'/respond/:token',
validateParams(tokenParamsSchema),
validateBody(submitQuestionnaireResponseSchema),
submitResponse
);

// ---------------------------------------------------------------------------
// Authenticated routes — require JWT + workspace membership
// Public /respond/:token routes live in questionnairePublicRoutes.js, mounted
// unguarded in app.js (ahead of requireActivePlan).
// ---------------------------------------------------------------------------

/**
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/features/questionnaires/api/questionnaires.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export interface CreateQuestionnaireDto {
// ---------------------------------------------------------------------------

const publicApiBaseURL =
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL
typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL
? process.env.NEXT_PUBLIC_API_URL
: '') + '/api/v1';
: '/api/v1';

const publicClient = axios.create({
baseURL: publicApiBaseURL,
Expand Down
Loading