diff --git a/backend/api/openapi.yaml b/backend/api/openapi.yaml index a1c4dab..ce434da 100644 --- a/backend/api/openapi.yaml +++ b/backend/api/openapi.yaml @@ -894,6 +894,25 @@ components: required: - lead_nuid type: object + CreatePreferenceListCommentInputBody: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/CreatePreferenceListCommentInputBody.json + format: uri + readOnly: true + type: string + application_id: + description: Application ID + type: string + body: + minLength: 1 + type: string + required: + - body + type: object CreatePreferenceListInputBody: additionalProperties: false properties: @@ -2087,6 +2106,42 @@ components: - created_at - updated_at type: object + PreferenceListCommentDetail: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/PreferenceListCommentDetail.json + format: uri + readOnly: true + type: string + application_id: + type: string + author_name: + type: string + author_nuid: + type: string + body: + type: string + created_at: + format: date-time + type: string + id: + type: string + preference_list_id: + type: string + updated_at: + format: date-time + type: string + required: + - id + - preference_list_id + - author_nuid + - body + - created_at + - updated_at + type: object PreferenceListDeadline: additionalProperties: false properties: @@ -2130,6 +2185,12 @@ components: format: uri readOnly: true type: string + comments: + items: + $ref: "#/components/schemas/PreferenceListCommentDetail" + type: + - array + - "null" created_at: format: date-time type: string @@ -2173,6 +2234,7 @@ components: - members - entries - personal_entries + - comments - id - cycle_id - name @@ -3106,6 +3168,22 @@ components: - challenge_tracks - post_interview_checklist type: object + UpdatePreferenceListCommentInputBody: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/UpdatePreferenceListCommentInputBody.json + format: uri + readOnly: true + type: string + body: + minLength: 1 + type: string + required: + - body + type: object UpdatePreferenceListInputBody: additionalProperties: false properties: @@ -7972,6 +8050,129 @@ paths: summary: Rename a preference list or toggle its submitted status tags: - Preference lists + /preference-lists/{id}/comments: + post: + description: Any group member (or chief/admin) may post. Omit application_id for a comment on the group as a whole; set it to comment on one applicant in the shared list. Never deadline-gated. + operationId: create-preference-list-comment + parameters: + - description: Preference list ID + in: path + name: id + required: true + schema: + description: Preference list ID + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePreferenceListCommentInputBody" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceListCommentDetail" + description: Created + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Internal Server Error + summary: Add a comment on a preference list group or one of its applicants + tags: + - Preference lists + /preference-lists/{id}/comments/{commentId}: + put: + description: Only the comment's own author may edit it. + operationId: update-preference-list-comment + parameters: + - description: Preference list ID + in: path + name: id + required: true + schema: + description: Preference list ID + type: string + - description: Comment ID + in: path + name: commentId + required: true + schema: + description: Comment ID + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePreferenceListCommentInputBody" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceListCommentDetail" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Internal Server Error + summary: Edit a preference list comment + tags: + - Preference lists /preference-lists/{id}/entries/{applicationId}: delete: operationId: delete-preference-list-entry diff --git a/backend/internal/handlers/preference_list_comments.go b/backend/internal/handlers/preference_list_comments.go new file mode 100644 index 0000000..84facb0 --- /dev/null +++ b/backend/internal/handlers/preference_list_comments.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "context" + + "github.com/danielgtaylor/huma/v2" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +type PreferenceListCommentOutput struct { + Body models.PreferenceListCommentDetail +} + +type CreatePreferenceListCommentInput struct { + ID string `path:"id" doc:"Preference list ID"` + Body struct { + // Omit for a comment on the group as a whole; set to comment on one + // applicant already in the shared list's cycle. + ApplicationID *string `json:"application_id,omitempty" doc:"Application ID"` + Body string `json:"body" minLength:"1"` + } +} + +func (h *preferenceListHandler) createComment(ctx context.Context, in *CreatePreferenceListCommentInput) (*PreferenceListCommentOutput, error) { + if err := requireReviewer(ctx); err != nil { + return nil, err + } + if err := h.requireAccess(ctx, in.ID); err != nil { + return nil, err + } + if in.Body.ApplicationID != nil { + list, err := h.store.GetPreferenceList(ctx, in.ID) + if err != nil { + return nil, storeErr(err) + } + app, err := h.store.GetApplication(ctx, *in.Body.ApplicationID) + if err != nil { + return nil, storeErr(err) + } + if app.CycleID != list.CycleID { + return nil, huma.Error422UnprocessableEntity("application is not in this list's cycle") + } + } + comment, err := h.store.CreatePreferenceListComment(ctx, in.ID, in.Body.ApplicationID, currentActor(ctx).NUID, in.Body.Body) + if err != nil { + return nil, storeErr(err) + } + return &PreferenceListCommentOutput{Body: comment}, nil +} + +type UpdatePreferenceListCommentInput struct { + ID string `path:"id" doc:"Preference list ID"` + CommentID string `path:"commentId" doc:"Comment ID"` + Body struct { + Body string `json:"body" minLength:"1"` + } +} + +func (h *preferenceListHandler) updateComment(ctx context.Context, in *UpdatePreferenceListCommentInput) (*PreferenceListCommentOutput, error) { + if err := requireReviewer(ctx); err != nil { + return nil, err + } + if err := h.requireAccess(ctx, in.ID); err != nil { + return nil, err + } + comment, err := h.store.UpdatePreferenceListComment(ctx, in.CommentID, currentActor(ctx).NUID, in.Body.Body) + if err != nil { + return nil, storeErr(err) + } + return &PreferenceListCommentOutput{Body: comment}, nil +} diff --git a/backend/internal/handlers/preference_lists.go b/backend/internal/handlers/preference_lists.go index 42eb72e..1b5dd98 100644 --- a/backend/internal/handlers/preference_lists.go +++ b/backend/internal/handlers/preference_lists.go @@ -154,6 +154,27 @@ func (h *preferenceListHandler) register(api huma.API) { Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusUnprocessableEntity}, }, h.reorderPersonalEntries) + huma.Register(api, huma.Operation{ + OperationID: "create-preference-list-comment", + Method: http.MethodPost, + Path: "/preference-lists/{id}/comments", + Summary: "Add a comment on a preference list group or one of its applicants", + Description: "Any group member (or chief/admin) may post. Omit application_id for a comment on the group as a whole; set it to comment on one applicant in the shared list. Never deadline-gated.", + Tags: []string{"Preference lists"}, + DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusUnprocessableEntity}, + }, h.createComment) + + huma.Register(api, huma.Operation{ + OperationID: "update-preference-list-comment", + Method: http.MethodPut, + Path: "/preference-lists/{id}/comments/{commentId}", + Summary: "Edit a preference list comment", + Description: "Only the comment's own author may edit it.", + Tags: []string{"Preference lists"}, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusUnprocessableEntity}, + }, h.updateComment) + huma.Register(api, huma.Operation{ OperationID: "get-preference-list-deadline", Method: http.MethodGet, diff --git a/backend/internal/models/preference_lists.go b/backend/internal/models/preference_lists.go index f4182e5..d4de4d2 100644 --- a/backend/internal/models/preference_lists.go +++ b/backend/internal/models/preference_lists.go @@ -114,15 +114,39 @@ type PreferenceListPersonalEntryDetail struct { ApplicationRole Role `json:"application_role"` } -// PreferenceListDetail bundles a list with its members, shared entries, and -// every member's personal entries for a single detail-page fetch, like -// WrittenReviewDetail bundling a review with its answers — personal entries -// for every member are fetched in bulk here rather than once per member. +// PreferenceListComment is an open comment within a group's shared list — +// mirrors InterviewComment's shape (any member may post, edit only their +// own). ApplicationID nil means a comment on the group as a whole; set means +// a comment on that one applicant/entry. Scoped to the shared list only, not +// personal lists. +type PreferenceListComment struct { + ID string `json:"id"` + PreferenceListID string `json:"preference_list_id"` + ApplicationID *string `json:"application_id,omitempty"` + AuthorNUID string `json:"author_nuid"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PreferenceListCommentDetail bundles a comment with the author's resolved +// display name (not a table column). +type PreferenceListCommentDetail struct { + PreferenceListComment + AuthorName string `json:"author_name,omitempty"` +} + +// PreferenceListDetail bundles a list with its members, shared entries, +// every member's personal entries, and every comment (group-level and +// per-entry) for a single detail-page fetch, like WrittenReviewDetail +// bundling a review with its answers — everything is fetched in bulk here +// rather than once per member/entry. type PreferenceListDetail struct { PreferenceList Members []PreferenceListMember `json:"members"` Entries []PreferenceListEntryDetail `json:"entries"` PersonalEntries []PreferenceListPersonalEntryDetail `json:"personal_entries"` + Comments []PreferenceListCommentDetail `json:"comments"` } // PreferenceListDeadline is a per-(cycle, role) settings row — not a column diff --git a/backend/internal/store/preference_list_comments.go b/backend/internal/store/preference_list_comments.go new file mode 100644 index 0000000..b84a6c5 --- /dev/null +++ b/backend/internal/store/preference_list_comments.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/GenerateNU/apportal/backend/internal/models" +) + +const preferenceListCommentColumns = `id, preference_list_id, application_id, author_nuid, body, created_at, updated_at` + +// CreatePreferenceListComment adds a comment. applicationID nil posts a +// comment on the group as a whole; set posts on that one applicant/entry. +// Any group member may leave any number of these. +func (s *Store) CreatePreferenceListComment(ctx context.Context, listID string, applicationID *string, authorNUID, body string) (models.PreferenceListCommentDetail, error) { + var detail models.PreferenceListCommentDetail + const q = ` + INSERT INTO preference_list_comments (preference_list_id, application_id, author_nuid, body) + VALUES ($1, $2, $3, $4) + RETURNING ` + preferenceListCommentColumns + rows, err := s.db.Query(ctx, q, listID, applicationID, authorNUID, body) + if err != nil { + return detail, err + } + comment, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.PreferenceListComment]) + if err != nil { + return detail, err + } + names, err := s.namesByNUIDs(ctx, []string{comment.AuthorNUID}) + if err != nil { + return detail, err + } + detail.PreferenceListComment = comment + detail.AuthorName = names[comment.AuthorNUID] + return detail, nil +} + +// UpdatePreferenceListComment edits a comment's body. Scoped to authorNUID so +// a lead can only edit their own comments — ErrNotFound covers both "doesn't +// exist" and "isn't yours" without distinguishing the two to the caller. +func (s *Store) UpdatePreferenceListComment(ctx context.Context, commentID, authorNUID, body string) (models.PreferenceListCommentDetail, error) { + var detail models.PreferenceListCommentDetail + const q = ` + UPDATE preference_list_comments SET body = $3, updated_at = NOW() + WHERE id = $1 AND author_nuid = $2 + RETURNING ` + preferenceListCommentColumns + rows, err := s.db.Query(ctx, q, commentID, authorNUID, body) + if err != nil { + return detail, err + } + comment, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[models.PreferenceListComment]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return detail, ErrNotFound + } + return detail, err + } + names, err := s.namesByNUIDs(ctx, []string{comment.AuthorNUID}) + if err != nil { + return detail, err + } + detail.PreferenceListComment = comment + detail.AuthorName = names[comment.AuthorNUID] + return detail, nil +} + +// ListPreferenceListComments returns every comment on a list — both +// group-level (application_id NULL) and per-entry — oldest first, for +// GetPreferenceListDetail to bundle in one fetch. +func (s *Store) ListPreferenceListComments(ctx context.Context, listID string) ([]models.PreferenceListCommentDetail, error) { + const q = `SELECT ` + preferenceListCommentColumns + ` FROM preference_list_comments WHERE preference_list_id = $1 ORDER BY created_at` + rows, err := s.db.Query(ctx, q, listID) + if err != nil { + return nil, err + } + comments, err := pgx.CollectRows(rows, pgx.RowToStructByPos[models.PreferenceListComment]) + if err != nil { + return nil, err + } + + nuids := make([]string, len(comments)) + for i, c := range comments { + nuids[i] = c.AuthorNUID + } + names, err := s.namesByNUIDs(ctx, nuids) + if err != nil { + return nil, err + } + + details := make([]models.PreferenceListCommentDetail, len(comments)) + for i, c := range comments { + details[i].PreferenceListComment = c + details[i].AuthorName = names[c.AuthorNUID] + } + return details, nil +} diff --git a/backend/internal/store/preference_lists.go b/backend/internal/store/preference_lists.go index 9bc8cc4..9afb18e 100644 --- a/backend/internal/store/preference_lists.go +++ b/backend/internal/store/preference_lists.go @@ -127,6 +127,12 @@ func (s *Store) GetPreferenceListDetail(ctx context.Context, id string) (models. } detail.PersonalEntries = personalEntries + comments, err := s.ListPreferenceListComments(ctx, id) + if err != nil { + return detail, err + } + detail.Comments = comments + return detail, nil } diff --git a/backend/internal/supabase/migrations/20260823100000_preference-list-comments.sql b/backend/internal/supabase/migrations/20260823100000_preference-list-comments.sql new file mode 100644 index 0000000..5769931 --- /dev/null +++ b/backend/internal/supabase/migrations/20260823100000_preference-list-comments.sql @@ -0,0 +1,20 @@ +-- An open comment thread within a preference-list group, for the leads on +-- it to discuss out loud — mirrors interview_comments' shape (any member may +-- post, edit only their own). application_id is nullable: NULL is a comment +-- on the group as a whole; set is a comment on that one applicant/entry. +-- Scoped to shared-list entries only, not personal lists. +CREATE TABLE preference_list_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + preference_list_id UUID NOT NULL REFERENCES preference_lists(id) ON DELETE CASCADE, + application_id UUID REFERENCES applications(id) ON DELETE CASCADE, + author_nuid TEXT NOT NULL REFERENCES users(nuid), + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_preference_list_comments_list ON preference_list_comments(preference_list_id); + +CREATE TRIGGER trg_preference_list_comments_updated_at + BEFORE UPDATE ON preference_list_comments + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/frontend/src/app/(portal)/reviewer/preference-lists/[id]/components/PreferenceListDetailClient.tsx b/frontend/src/app/(portal)/reviewer/preference-lists/[id]/components/PreferenceListDetailClient.tsx index 128f8cd..2e59a98 100644 --- a/frontend/src/app/(portal)/reviewer/preference-lists/[id]/components/PreferenceListDetailClient.tsx +++ b/frontend/src/app/(portal)/reviewer/preference-lists/[id]/components/PreferenceListDetailClient.tsx @@ -2,7 +2,18 @@ import { useState } from 'react' import Link from 'next/link' -import { ArrowDown, ArrowLeft, ArrowUp, Lock, Trash2, X } from 'lucide-react' +import { + ArrowDown, + ArrowLeft, + ArrowUp, + ChevronDown, + ChevronUp, + Loader2, + Lock, + Pencil, + Trash2, + X, +} from 'lucide-react' import { PageContainer } from '@/components/PageContainer' import { Button } from '@/components/ui/button' import { DateTimePicker } from '@/components/ui/datetime-picker' @@ -23,11 +34,17 @@ import { MEETING_DAY_LABEL, } from '@/app/(portal)/reviewer/applications/components/meetingAvailability' import { APIError } from '@/lib/api/client' -import type { MeetingDay, PreferenceListStatus, Role } from '@/lib/api/types' +import type { + MeetingDay, + PreferenceListComment, + PreferenceListStatus, + Role, +} from '@/lib/api/types' import { useAnswersByApplicationIdBatches } from '@/lib/queries/answers' import { useApplications } from '@/lib/queries/applications' import { useAddPreferenceListMember, + useCreatePreferenceListComment, useDeletePersonalPreferenceListEntry, useDeletePreferenceList, useDeletePreferenceListEntry, @@ -39,6 +56,7 @@ import { useSetPreferenceListDeadline, useSetPreferenceListMeetingDay, useUpdatePreferenceList, + useUpdatePreferenceListComment, useUpsertPersonalPreferenceListEntry, useUpsertPreferenceListEntry, } from '@/lib/queries/preference-lists' @@ -176,6 +194,8 @@ export function PreferenceListDetailClient({ const reorderPersonalEntries = useReorderPersonalPreferenceListEntries() const setDeadline = useSetPreferenceListDeadline() const setMeetingDay = useSetPreferenceListMeetingDay() + const createComment = useCreatePreferenceListComment() + const updateComment = useUpdatePreferenceListComment() const [name, setName] = useState('') const [nameSeeded, setNameSeeded] = useState(false) @@ -233,6 +253,14 @@ export function PreferenceListDetailClient({ return availabilityBadge(isAvailableOn(options, meetingDay)) } + // Comments are scoped to the shared list only, not personal lists. + // application_id absent is a comment on the group as a whole. + const comments = list.comments + const groupComments = comments.filter((c) => !c.application_id) + function commentsForEntry(applicationId: string) { + return comments.filter((c) => c.application_id === applicationId) + } + const roleEntries = list.entries.filter( (e) => e.application_role === selectedRole ) @@ -568,6 +596,20 @@ export function PreferenceListDetailClient({ reasoning, }) } + comments={commentsForEntry(entry.application_id)} + currentUserNuid={currentUser?.nuid} + onAddComment={(body) => + createComment.mutate({ + listId: list.id, + applicationId: entry.application_id, + body, + }) + } + onEditComment={(commentId, body) => + updateComment.mutate({ listId: list.id, commentId, body }) + } + isAddingComment={createComment.isPending} + isEditingComment={updateComment.isPending} /> ))} {roleEntries.length === 0 && ( @@ -652,6 +694,23 @@ export function PreferenceListDetailClient({ > )} + +
+ {c.author_name || c.author_nuid} +
++ {c.body} +
++ {new Date(c.created_at).toLocaleString()} + {edited && ' · edited'} +
+