Skip to content

Commit 63f473d

Browse files
authored
Merge pull request #9233 from Couchers-org/web/feature/incomplete-profile-event-comment
Block incomplete profiles from commenting on all threads
2 parents 8ffe19e + 34ba5e0 commit 63f473d

9 files changed

Lines changed: 212 additions & 13 deletions

File tree

app/backend/src/couchers/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"incomplete_profile_create_discussion": "You have to complete your profile before you can start a discussion.",
9999
"incomplete_profile_create_event": "You have to complete your profile before you can create an event.",
100100
"incomplete_profile_create_public_trip": "You have to complete your profile before you can create a public trip.",
101+
"incomplete_profile_post_comment": "You have to complete your profile before you can post a comment.",
101102
"incomplete_profile_send_friend_request": "You have to complete your profile before you can send a friend request.",
102103
"incomplete_profile_send_message": "You have to complete your profile before you can send a message.",
103104
"incomplete_profile_send_request": "You have to complete your profile before you can send a request.",

app/backend/src/couchers/servicers/threads.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from couchers.context import CouchersContext, make_background_user_context, make_notification_user_context
1111
from couchers.db import session_scope
12+
from couchers.helpers.completed_profile import has_completed_profile
1213
from couchers.jobs.enqueue import queue_job
1314
from couchers.models import (
1415
Comment,
@@ -385,6 +386,10 @@ def PostReply(
385386
if depth not in (0, 1):
386387
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found")
387388

389+
user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
390+
if not has_completed_profile(session, user):
391+
context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_post_comment")
392+
388393
object_to_add: Comment | Reply | None = None
389394

390395
def create_object(moderation_state_id: int) -> int:

app/backend/src/tests/test_threads.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import string
22
import textwrap
3+
from datetime import timedelta
34

45
import grpc
56
import pytest
@@ -17,11 +18,12 @@
1718
User,
1819
)
1920
from couchers.models.discussions import CommentVersion, ContentChangeType, ReplyVersion
20-
from couchers.proto import moderation_pb2, threads_pb2
21+
from couchers.proto import discussions_pb2, events_pb2, moderation_pb2, threads_pb2
2122
from couchers.servicers.threads import pack_thread_id
22-
from couchers.utils import now
23+
from couchers.utils import Timestamp_from_datetime, now
2324
from tests.fixtures.db import generate_user
24-
from tests.fixtures.sessions import real_moderation_session, threads_session
25+
from tests.fixtures.sessions import discussions_session, events_session, real_moderation_session, threads_session
26+
from tests.test_communities import create_community
2527

2628

2729
@pytest.fixture(autouse=True)
@@ -539,6 +541,101 @@ def test_delete_reply_creates_version_record(db):
539541
assert v.editor_user_id == user.id
540542

541543

544+
def _create_event_and_get_thread_id(organizer, organizer_token: str) -> int:
545+
"""Helper: create an Event via the API, return its (packed) thread_id."""
546+
with session_scope() as session:
547+
create_community(session, 0, 2, "Testing Community", [organizer], [], None)
548+
549+
start_time = now() + timedelta(hours=2)
550+
end_time = start_time + timedelta(hours=3)
551+
with events_session(organizer_token) as api:
552+
res = api.CreateEvent(
553+
events_pb2.CreateEventReq(
554+
title="Dummy event",
555+
content="Dummy content.",
556+
location=events_pb2.EventLocation(
557+
address="Near Null Island",
558+
lat=0.1,
559+
lng=0.2,
560+
),
561+
start_time=Timestamp_from_datetime(start_time),
562+
end_time=Timestamp_from_datetime(end_time),
563+
timezone="UTC",
564+
)
565+
)
566+
return int(res.thread.thread_id)
567+
568+
569+
def test_post_reply_incomplete_profile_blocked_on_event_comment(db):
570+
"""An incomplete-profile user cannot post a top-level comment on an event's thread."""
571+
organizer, organizer_token = generate_user()
572+
_commenter, commenter_token = generate_user(complete_profile=False)
573+
574+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
575+
576+
with threads_session(commenter_token) as api:
577+
with pytest.raises(grpc.RpcError) as e:
578+
api.PostReply(threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello"))
579+
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
580+
581+
582+
def test_post_reply_incomplete_profile_blocked_on_event_reply(db):
583+
"""An incomplete-profile user cannot post a nested reply within an event's thread."""
584+
organizer, organizer_token = generate_user()
585+
_commenter, commenter_token = generate_user()
586+
_replier, replier_token = generate_user(complete_profile=False)
587+
588+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
589+
590+
with threads_session(commenter_token) as api:
591+
comment_thread_id = api.PostReply(
592+
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="top-level comment")
593+
).thread_id
594+
595+
with threads_session(replier_token) as api:
596+
with pytest.raises(grpc.RpcError) as e:
597+
api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="a reply"))
598+
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
599+
600+
601+
def test_post_reply_complete_profile_allowed_on_event_comment(db):
602+
"""A complete-profile user can post a comment on an event's thread."""
603+
organizer, organizer_token = generate_user()
604+
_commenter, commenter_token = generate_user()
605+
606+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
607+
608+
with threads_session(commenter_token) as api:
609+
comment_thread_id = api.PostReply(
610+
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello")
611+
).thread_id
612+
assert comment_thread_id
613+
614+
615+
def test_post_reply_incomplete_profile_blocked_on_discussion_thread(db):
616+
"""An incomplete-profile user cannot post a comment on a discussion thread."""
617+
admin, admin_token = generate_user()
618+
_commenter, commenter_token = generate_user(complete_profile=False)
619+
620+
with session_scope() as session:
621+
community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id
622+
623+
with discussions_session(admin_token) as api:
624+
discussion = api.CreateDiscussion(
625+
discussions_pb2.CreateDiscussionReq(
626+
title="A discussion",
627+
content="Content",
628+
owner_community_id=community_id,
629+
)
630+
)
631+
discussion_thread_id = discussion.thread.thread_id
632+
633+
with threads_session(commenter_token) as api:
634+
with pytest.raises(grpc.RpcError) as e:
635+
api.PostReply(threads_pb2.PostReplyReq(thread_id=discussion_thread_id, content="hello"))
636+
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
637+
638+
542639
def test_edit_comment_multiple_edits_creates_multiple_version_records(db):
543640
user, token = generate_user()
544641
_, comment_thread_id = _make_thread_and_comment(token, content="v1")

app/web/components/Comments/NewComment.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
11
import { Box, Button, Grid, Link } from "@mui/material";
22
import Markdown from "components/Markdown";
3+
import ProfileIncompleteDialog from "components/ProfileIncompleteDialog/ProfileIncompleteDialog";
34
import TextField from "components/TextField";
5+
import useAccountInfo from "features/auth/useAccountInfo";
46
import React, { useState } from "react";
57

68
interface NewCommentProps {
79
onComment: (comment: string) => Promise<void>;
810
}
911

1012
export default function NewComment({ onComment }: NewCommentProps) {
13+
const { data: accountInfo } = useAccountInfo();
1114
const [preview, setPreview] = useState(false);
1215
const [comment, setComment] = useState("");
16+
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
1317

1418
const handleSubmit = async () => {
19+
if (accountInfo?.profileComplete === false) {
20+
setProfileDialogOpen(true);
21+
return;
22+
}
1523
await onComment(comment);
1624
setComment("");
1725
};
1826

1927
return (
2028
<>
29+
<ProfileIncompleteDialog
30+
open={profileDialogOpen}
31+
onClose={() => setProfileDialogOpen(false)}
32+
attempted_action="post_comment"
33+
/>
2134
<p>Write a comment:</p>
2235
<Grid container spacing={2}>
2336
<Grid size={{ xs: 12, md: preview ? 6 : 12 }}>

app/web/components/ProfileIncompleteDialog/ProfileIncompleteDialog.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ import Link from "next/link";
1313
import React from "react";
1414
import { howToCompleteProfileUrl, routeToEditProfile } from "routes";
1515

16+
export type ProfileIncompleteAction =
17+
| "create_event"
18+
| "send_message"
19+
| "send_request"
20+
| "create_public_trip"
21+
| "send_friend_request"
22+
| "create_discussion"
23+
| "post_comment";
24+
1625
interface ProfileIncompleteDialogProps {
1726
open: boolean;
1827
onClose: () => void;
19-
attempted_action:
20-
| "create_event"
21-
| "send_message"
22-
| "send_request"
23-
| "create_public_trip"
24-
| "send_friend_request"
25-
| "create_discussion";
28+
attempted_action: ProfileIncompleteAction;
2629
}
2730

2831
export default function ProfileIncompleteDialog({

app/web/features/communities/PageHeader.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const StyledWrapper = styled("div")(({ theme }) => ({
2121
},
2222
[theme.breakpoints.up("md")]: {
2323
height: "16rem",
24-
marginTop: theme.spacing(-2),
2524
},
2625
}));
2726

app/web/features/communities/discussions/CommentForm.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { discussionBaseRoute } from "routes";
55
import { service } from "service";
66
import wrapper from "test/hookWrapper";
77
import i18n from "test/i18n";
8+
import { getAccountInfo } from "test/serviceMockDefaults";
89
import { MockedService } from "test/utils";
910

1011
import CommentForm from "./CommentForm";
@@ -16,6 +17,9 @@ jest.mock("components/MarkdownInput");
1617
const postReplyMock = service.threads.postReply as MockedService<
1718
typeof service.threads.postReply
1819
>;
20+
const getAccountInfoMock = service.account.getAccountInfo as MockedService<
21+
typeof service.account.getAccountInfo
22+
>;
1923

2024
function renderCommentForm() {
2125
mockRouter.setCurrentUrl(
@@ -24,6 +28,11 @@ function renderCommentForm() {
2428
render(<CommentForm threadId={999} shown={true} />, { wrapper });
2529
}
2630

31+
beforeEach(() => {
32+
// Default to a complete profile so non-gate tests don't trip the incomplete-profile dialog.
33+
getAccountInfoMock.mockImplementation(getAccountInfo);
34+
});
35+
2736
describe("Comment form", () => {
2837
beforeAll(() => {
2938
jest.useFakeTimers();
@@ -96,3 +105,59 @@ describe("Comment form", () => {
96105
expect(postReplyMock).not.toHaveBeenCalled();
97106
});
98107
});
108+
109+
describe("Comment form profile-completeness gate", () => {
110+
beforeAll(() => {
111+
// The "Comment form" describe above leaves fake timers active; restore
112+
// real timers so `await user.type`/`user.click` resolve normally here.
113+
jest.useRealTimers();
114+
});
115+
116+
beforeEach(() => {
117+
postReplyMock.mockClear();
118+
});
119+
120+
it("shows the profile incomplete dialog instead of posting when the profile is incomplete", async () => {
121+
getAccountInfoMock.mockImplementation(async () => ({
122+
...(await getAccountInfo()),
123+
profileComplete: false,
124+
}));
125+
renderCommentForm();
126+
127+
const user = userEvent.setup();
128+
129+
const commentInput = (await screen.findByLabelText(
130+
t("communities:write_comment_a11y_label"),
131+
)) as HTMLInputElement;
132+
await user.type(commentInput, "This is a valid comment");
133+
134+
await user.click(
135+
screen.getByRole("button", { name: t("communities:comment") }),
136+
);
137+
138+
expect(
139+
await screen.findByLabelText(t("profile:complete_profile_dialog.title")),
140+
).toBeVisible();
141+
expect(postReplyMock).not.toHaveBeenCalled();
142+
});
143+
144+
it("posts the comment as usual when the profile is complete", async () => {
145+
renderCommentForm();
146+
147+
const user = userEvent.setup();
148+
149+
const commentInput = (await screen.findByLabelText(
150+
t("communities:write_comment_a11y_label"),
151+
)) as HTMLInputElement;
152+
await user.type(commentInput, "This is a valid comment");
153+
154+
await user.click(
155+
screen.getByRole("button", { name: t("communities:comment") }),
156+
);
157+
158+
await waitFor(() => expect(postReplyMock).toHaveBeenCalledTimes(1));
159+
expect(
160+
screen.queryByLabelText(t("profile:complete_profile_dialog.title")),
161+
).not.toBeInTheDocument();
162+
});
163+
});

app/web/features/communities/discussions/CommentForm.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
44
import Alert from "components/Alert";
55
import Button from "components/Button";
66
import MarkdownInput, { MarkdownInputProps } from "components/MarkdownInput";
7+
import ProfileIncompleteDialog from "components/ProfileIncompleteDialog/ProfileIncompleteDialog";
8+
import useAccountInfo from "features/auth/useAccountInfo";
79
import { RpcError } from "grpc-web";
810
import { useTranslation } from "i18n";
911
import { COMMUNITIES, GLOBAL } from "i18n/namespaces";
10-
import React, { useRef } from "react";
12+
import React, { useRef, useState } from "react";
1113
import { useForm } from "react-hook-form";
1214
import { service } from "service";
1315
import { theme } from "theme";
@@ -48,6 +50,9 @@ function InternalCommentForm(
4850
ref: React.ForwardedRef<HTMLFormElement>,
4951
) {
5052
const { t } = useTranslation([GLOBAL, COMMUNITIES]);
53+
const { data: accountInfo } = useAccountInfo();
54+
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
55+
const profileIncomplete = accountInfo?.profileComplete === false;
5156
const {
5257
control,
5358
handleSubmit,
@@ -75,6 +80,11 @@ function InternalCommentForm(
7580
});
7681

7782
const onSubmit = handleSubmit((data) => {
83+
if (profileIncomplete) {
84+
setProfileDialogOpen(true);
85+
return;
86+
}
87+
7888
const trimmedValue = data.content.trim();
7989
const newData = {
8090
content: trimmedValue,
@@ -85,6 +95,11 @@ function InternalCommentForm(
8595

8696
return (
8797
<Collapse data-testid={`comment-${threadId}-comment-form`} in={shown}>
98+
<ProfileIncompleteDialog
99+
open={profileDialogOpen}
100+
onClose={() => setProfileDialogOpen(false)}
101+
attempted_action="post_comment"
102+
/>
88103
<StyledForm onSubmit={onSubmit} ref={ref}>
89104
{error && <Alert severity="error">{error.message}</Alert>}
90105
<span style={visuallyHidden} id={`comment-${threadId}-reply-label`}>

app/web/features/profile/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,8 @@
341341
"send_friend_request": "send a friend request",
342342
"create_event": "create an event",
343343
"create_public_trip": "create a public trip",
344-
"create_discussion": "create a discussion"
344+
"create_discussion": "create a discussion",
345+
"post_comment": "post a comment"
345346
},
346347
"edit_profile_button": "Edit your profile now",
347348
"cancel_button": "Never mind"

0 commit comments

Comments
 (0)