Skip to content

Collaborative signal editing (user groups etc) #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 30, 2025
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
16 changes: 16 additions & 0 deletions sql/user_groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Create a single user groups table with direct arrays for signals and users
CREATE TABLE IF NOT EXISTS user_groups (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
signal_ids INTEGER[] NOT NULL DEFAULT '{}',
user_ids INTEGER[] NOT NULL DEFAULT '{}',
-- Store collaborator relationships as JSON
-- Format: {"signal_id": [user_id1, user_id2], ...}
collaborator_map JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Create GIN indexes for faster array lookups
CREATE INDEX IF NOT EXISTS idx_user_groups_signal_ids ON user_groups USING GIN (signal_ids);
CREATE INDEX IF NOT EXISTS idx_user_groups_user_ids ON user_groups USING GIN (user_ids);
CREATE INDEX IF NOT EXISTS idx_user_groups_collaborator_map ON user_groups USING GIN (collaborator_map);
1 change: 1 addition & 0 deletions src/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
from .signals import *
from .trends import *
from .users import *
from .user_groups import *
196 changes: 196 additions & 0 deletions src/database/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"delete_signal",
"read_user_signals",
"is_signal_favorited",
"add_collaborator",
"remove_collaborator",
"get_signal_collaborators",
"can_user_edit_signal",
]


Expand Down Expand Up @@ -392,3 +396,195 @@ async def is_signal_favorited(cursor: AsyncCursor, user_email: str, signal_id: i
"""
await cursor.execute(query, (user_email, signal_id))
return await cursor.fetchone() is not None



async def add_collaborator(cursor: AsyncCursor, signal_id: int, collaborator: str) -> bool:
"""
Add a collaborator to a signal.

Parameters
----------
cursor : AsyncCursor
An async database cursor.
signal_id : int
The ID of the signal.
collaborator : str
The email of the user or "group:{id}" to add as a collaborator.

Returns
-------
bool
True if the collaborator was added, False otherwise.
"""
# Check if the signal exists
await cursor.execute("SELECT 1 FROM signals WHERE id = %s;", (signal_id,))
if await cursor.fetchone() is None:
return False

# Determine if this is a group or user
if collaborator.startswith("group:"):
group_id = int(collaborator.split(":")[1])
query = """
INSERT INTO signal_collaborator_groups (signal_id, group_id)
VALUES (%s, %s)
ON CONFLICT (signal_id, group_id) DO NOTHING
RETURNING signal_id
;
"""
await cursor.execute(query, (signal_id, group_id))
else:
# Check if the user exists
await cursor.execute("SELECT 1 FROM users WHERE email = %s;", (collaborator,))
if await cursor.fetchone() is None:
return False

query = """
INSERT INTO signal_collaborators (signal_id, user_email)
VALUES (%s, %s)
ON CONFLICT (signal_id, user_email) DO NOTHING
RETURNING signal_id
;
"""
await cursor.execute(query, (signal_id, collaborator))

return await cursor.fetchone() is not None


async def remove_collaborator(cursor: AsyncCursor, signal_id: int, collaborator: str) -> bool:
"""
Remove a collaborator from a signal.

Parameters
----------
cursor : AsyncCursor
An async database cursor.
signal_id : int
The ID of the signal.
collaborator : str
The email of the user or "group:{id}" to remove as a collaborator.

Returns
-------
bool
True if the collaborator was removed, False otherwise.
"""
# Determine if this is a group or user
if collaborator.startswith("group:"):
group_id = int(collaborator.split(":")[1])
query = """
DELETE FROM signal_collaborator_groups
WHERE signal_id = %s AND group_id = %s
RETURNING signal_id
;
"""
await cursor.execute(query, (signal_id, group_id))
else:
query = """
DELETE FROM signal_collaborators
WHERE signal_id = %s AND user_email = %s
RETURNING signal_id
;
"""
await cursor.execute(query, (signal_id, collaborator))

return await cursor.fetchone() is not None


async def get_signal_collaborators(cursor: AsyncCursor, signal_id: int) -> list[str]:
"""
Get all collaborators for a signal.

Parameters
----------
cursor : AsyncCursor
An async database cursor.
signal_id : int
The ID of the signal.

Returns
-------
list[str]
A list of user emails and group IDs (as "group:{id}").
"""
# Get individual collaborators
query1 = """
SELECT user_email
FROM signal_collaborators
WHERE signal_id = %s
;
"""
await cursor.execute(query1, (signal_id,))
user_emails = [row[0] async for row in cursor]

# Get group collaborators
query2 = """
SELECT group_id
FROM signal_collaborator_groups
WHERE signal_id = %s
;
"""
await cursor.execute(query2, (signal_id,))
group_ids = [f"group:{row[0]}" async for row in cursor]

return user_emails + group_ids


async def can_user_edit_signal(cursor: AsyncCursor, signal_id: int, user_email: str) -> bool:
"""
Check if a user can edit a signal.

A user can edit a signal if:
1. They created the signal
2. They are in the collaborators list
3. They are part of a group in the collaborators list

Parameters
----------
cursor : AsyncCursor
An async database cursor.
signal_id : int
The ID of the signal.
user_email : str
The email of the user.

Returns
-------
bool
True if the user can edit the signal, False otherwise.
"""
# Check if the user created the signal
query1 = """
SELECT 1
FROM signals
WHERE id = %s AND created_by = %s
;
"""
await cursor.execute(query1, (signal_id, user_email))
if await cursor.fetchone() is not None:
return True

# Check if the user is in the collaborators list
query2 = """
SELECT 1
FROM signal_collaborators
WHERE signal_id = %s AND user_email = %s
;
"""
await cursor.execute(query2, (signal_id, user_email))
if await cursor.fetchone() is not None:
return True

# Check if the user is part of a group in the collaborators list
query3 = """
SELECT 1
FROM signal_collaborator_groups scg
JOIN user_group_members ugm ON scg.group_id = ugm.group_id
WHERE scg.signal_id = %s AND ugm.user_email = %s
;
"""
await cursor.execute(query3, (signal_id, user_email))
if await cursor.fetchone() is not None:
return True

return False
Loading