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
538 changes: 538 additions & 0 deletions api/v1_comment_notifications_test.go

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions database/test_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ func CreateTestDatabase(t *testing.T, template string) *pgxpool.Pool {
testMutex.Lock()
defer testMutex.Unlock()

conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/"+template)
// Connect to the maintenance DB, not the template itself: CREATE DATABASE
// ... TEMPLATE fails with SQLSTATE 55006 if any session is connected to the
// source. go test runs each package as its own process, so a Go-level mutex
// can't prevent one process holding a template connection open while another
// clones it.
conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/postgres")
if err != nil {
panic(fmt.Errorf("failed to connect to database: %w", err))
}
Expand All @@ -55,7 +60,7 @@ func CreateTestDatabase(t *testing.T, template string) *pgxpool.Pool {
testMutex.Lock()
defer testMutex.Unlock()

conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/"+template)
conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/postgres")
require.NoError(t, err)
defer conn.Close(ctx)

Expand Down
147 changes: 147 additions & 0 deletions ddl/functions/handle_comment_mention.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
-- handle_comment_mention
--
-- Emits a `comment_mention` notification to a mentioned user when a
-- comment_mentions row is inserted (or undeleted). Mirrors apps'
-- src/tasks/entity_manager/entities/comment.py mention notification
-- block (notification type "comment_mention" with group_id
-- "comment_mention:<comment_id>").
--
-- Why this fires on comment_mentions (not comments):
-- The mention rows are written AFTER the comments row in the same
-- indexer transaction (etl/processors/entity_manager/comment_create.go).
-- Hooking the child table lets a plain AFTER INSERT trigger see
-- everything it needs without DEFERRED gymnastics — the comments row
-- already exists by the time comment_mentions is inserted.
--
-- Skips (mirror apps):
-- - mention == commenter (self-mention)
-- - mentioned user has muted the commenter (muted_users)
-- - if mention == entity owner AND owner has notifications off for
-- this entity, skip — the entity owner already opted out
--
-- Deferred (intentional): apps also drops mentions when the commenter is
-- karma-muted (1.7M-follower-aggregate threshold across the muting
-- users). Not ported here for the same reason as
-- handle_comment_notification.sql — see header there.
create or replace function handle_comment_mention() returns trigger as $$
declare
c_row record;
entity_user_id int;
data_entity_ref int;
is_self_mention boolean;
mention_muted boolean;
owner_mute boolean;
is_owner_mention boolean;
begin
if new.is_delete then
return null;
end if;

-- Fetch the parent comment for entity context + author.
select user_id, entity_type, entity_id, blocknumber, created_at, is_delete, is_visible
into c_row
from comments
where comment_id = new.comment_id
limit 1;
if not found or c_row.is_delete or not c_row.is_visible then
return null;
end if;

-- Self-mention is a no-op.
if new.user_id = c_row.user_id then
return null;
end if;

-- Resolve entity owner — used for the "owner has notifications off"
-- gate when the mention IS the owner.
if c_row.entity_type = 'Track' then
select t.owner_id into entity_user_id
from tracks t
where t.track_id = c_row.entity_id
and t.is_current = true
limit 1;
data_entity_ref := c_row.entity_id;
elsif c_row.entity_type = 'Event' then
select e.user_id into entity_user_id
from events e
where e.event_id = c_row.entity_id
and e.is_deleted = false
limit 1;
data_entity_ref := c_row.entity_id;
elsif c_row.entity_type = 'FanClub' then
entity_user_id := c_row.entity_id;
data_entity_ref := c_row.entity_id;
else
return null;
end if;

is_owner_mention := (entity_user_id is not null and new.user_id = entity_user_id);

-- Mentioned user has muted the commenter — skip.
select exists (
select 1 from muted_users mu
where mu.user_id = new.user_id
and mu.muted_user_id = c_row.user_id
and mu.is_delete = false
) into mention_muted;
if mention_muted then
return null;
end if;

-- If the mention is the entity owner AND the owner muted notifications
-- on this entity, skip — matches apps' track_owner_mention_mute logic.
if is_owner_mention then
select exists (
select 1 from comment_notification_settings cns
where cns.user_id = entity_user_id
and cns.entity_type = c_row.entity_type
and cns.entity_id = data_entity_ref
and cns.is_muted = true
) or exists (
select 1 from muted_users mu
where mu.user_id = entity_user_id
and mu.muted_user_id = c_row.user_id
and mu.is_delete = false
) into owner_mute;
if owner_mute then
return null;
end if;
end if;

insert into notification
(blocknumber, user_ids, timestamp, type, specifier, group_id, data)
values
(
c_row.blocknumber,
ARRAY[new.user_id],
c_row.created_at,
'comment_mention',
new.user_id::text,
'comment_mention:' || new.comment_id,
jsonb_build_object(
'type', c_row.entity_type,
'entity_id', data_entity_ref,
'entity_user_id', entity_user_id,
'comment_user_id', c_row.user_id,
'comment_id', new.comment_id
)
)
on conflict do nothing;

return null;

exception
when others then
raise warning 'An error occurred in %: %', tg_name, sqlerrm;
return null;
end;
$$ language plpgsql;


do $$ begin
create trigger on_comment_mention
after insert on comment_mentions
for each row execute procedure handle_comment_mention();
exception
when others then null;
end $$;
151 changes: 151 additions & 0 deletions ddl/functions/handle_comment_notification.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
-- handle_comment_notification
--
-- Emits a `comment` notification to the entity owner (track owner / event
-- host / fan-club artist) when someone leaves a top-level comment on
-- their entity.
--
-- Sibling of:
-- handle_comment.sql (aggregate_track counts only)
-- handle_comment_remix_contest_update.sql (Event subscriber fan-out)
-- handle_fan_club_text_post.sql (FanClub follower fan-out)
--
-- Mirrors apps' src/tasks/entity_manager/entities/comment.py top-level
-- `comment` Notification block (notification type "comment" with group_id
-- "comment:<entity_id>:type:<entity_type>").
--
-- Why DEFERRABLE INITIALLY DEFERRED:
-- "Top-level" means no comment_threads row for this comment_id, and
-- "owner is mentioned" means a comment_mentions row exists with the
-- owner's user_id. Both of those sibling rows are inserted AFTER the
-- comments row in the same indexer transaction. A non-deferred trigger
-- would misclassify replies as top-level and miss owner-mention skips.
-- Same pattern as handle_comment_remix_contest_update.sql.
--
-- Deferred features (intentional): apps also checks a karma-based mute
-- where a commenter's muters' aggregate follower_count must be < a
-- threshold (default 1.7M prod, 4k dev). Not ported here — keeps the
-- trigger localized and the threshold lives in apps' config not the DB.
-- If noise becomes a problem we can fold it into a follow-up.
create or replace function handle_comment_notification() returns trigger as $$
declare
entity_user_id int;
data_entity_ref int;
group_id_str text;
is_reply boolean;
owner_mentioned boolean;
owner_mute boolean;
begin
if new.is_delete or not new.is_visible then
return null;
end if;

-- Resolve recipient (entity_user_id) + data.entity_id by entity_type.
if new.entity_type = 'Track' then
select t.owner_id into entity_user_id
from tracks t
where t.track_id = new.entity_id
and t.is_current = true
limit 1;
data_entity_ref := new.entity_id;
elsif new.entity_type = 'Event' then
select e.user_id into entity_user_id
from events e
where e.event_id = new.entity_id
and e.is_deleted = false
limit 1;
data_entity_ref := new.entity_id;
elsif new.entity_type = 'FanClub' then
-- For FanClub, entity_id IS the artist's user_id.
entity_user_id := new.entity_id;
data_entity_ref := new.entity_id;
else
return null;
end if;

if entity_user_id is null then
return null;
end if;

-- Skip self-comment.
if new.user_id = entity_user_id then
return null;
end if;

-- Skip replies (they emit comment_thread instead, to the parent
-- comment author). Deferred so comment_threads is visible.
select exists (
select 1 from comment_threads where comment_id = new.comment_id
) into is_reply;
if is_reply then
return null;
end if;

-- Skip if owner is mentioned in this comment (they get comment_mention
-- instead, also more specific). Deferred so comment_mentions is visible.
select exists (
select 1 from comment_mentions
where comment_id = new.comment_id
and user_id = entity_user_id
and is_delete = false
) into owner_mentioned;
if owner_mentioned then
return null;
end if;

-- Skip if owner muted notifications on this entity (CommentNotificationSetting
-- with is_muted=true) OR muted this commenter (MutedUser).
select exists (
select 1 from comment_notification_settings cns
where cns.user_id = entity_user_id
and cns.entity_type = new.entity_type
and cns.entity_id = data_entity_ref
and cns.is_muted = true
) or exists (
select 1 from muted_users mu
where mu.user_id = entity_user_id
and mu.muted_user_id = new.user_id
and mu.is_delete = false
) into owner_mute;
if owner_mute then
return null;
end if;

group_id_str := 'comment:' || data_entity_ref || ':type:' || new.entity_type;

insert into notification
(blocknumber, user_ids, timestamp, type, specifier, group_id, data)
values
(
new.blocknumber,
ARRAY[entity_user_id],
new.created_at,
'comment',
new.comment_id::text,
group_id_str,
jsonb_build_object(
'type', new.entity_type,
'entity_id', data_entity_ref,
'comment_user_id', new.user_id,
'comment_id', new.comment_id
)
)
on conflict do nothing;

return null;

exception
when others then
raise warning 'An error occurred in %: %', tg_name, sqlerrm;
return null;
end;
$$ language plpgsql;


do $$ begin
create constraint trigger on_comment_notification
after insert on comments
deferrable initially deferred
for each row execute procedure handle_comment_notification();
exception
when others then null;
end $$;
Loading
Loading