diff --git a/nephthys/actions/create_category_tag.py b/nephthys/actions/create_category_tag.py index 87aee3e..3ea5e20 100644 --- a/nephthys/actions/create_category_tag.py +++ b/nephthys/actions/create_category_tag.py @@ -1,4 +1,5 @@ import logging +import re from slack_bolt.async_app import AsyncAck from slack_sdk.web.async_client import AsyncWebClient @@ -31,17 +32,31 @@ async def create_category_tag_view_callback( ack: AsyncAck, body: dict, client: AsyncWebClient ): user_id = body["user"]["id"] + values = body["view"]["state"]["values"] - raw_name = body["view"]["state"]["values"]["category_tag_name"][ - "category_tag_name" - ]["value"] + raw_name = values["category_tag_name"]["category_tag_name"]["value"] name = raw_name.strip() if raw_name else "" + raw_slug = values["category_tag_slug"]["category_tag_slug"]["value"] + slug = raw_slug.strip() if raw_slug else None + + raw_description = values["category_tag_description"]["category_tag_description"][ + "value" + ] + description = raw_description.strip() if raw_description else None + + errors = {} + if not name: - await ack( - response_action="errors", - errors={"category_tag_name": "Category name cannot be empty."}, + errors["category_tag_name"] = "Category name cannot be empty." + + if slug and not re.match(r"^[a-z0-9_]+$", slug): + errors["category_tag_slug"] = ( + "Slug must be snake_case (lowercase letters, numbers, and underscores only)." ) + + if errors: + await ack(response_action="errors", errors=errors) return user = await env.db.user.find_unique(where={"slackId": user_id}) @@ -53,17 +68,20 @@ async def create_category_tag_view_callback( return try: - await env.db.categorytag.create( - data={"name": name, "createdBy": {"connect": {"id": user.id}}} - ) - except UniqueViolationError: - logging.warning(f"Duplicate category tag name: {name}") - await ack( - response_action="errors", - errors={ - "category_tag_name": f"A category tag named '{name}' already exists." - }, + data: dict = {"name": name, "createdBy": {"connect": {"id": user.id}}} + if slug: + data["slug"] = slug + if description: + data["description"] = description + + await env.db.categorytag.create(data=data) + except UniqueViolationError as e: + logging.warning(f"Duplicate category tag: {e}") + error_field = ( + "category_tag_slug" if slug and "slug" in str(e) else "category_tag_name" ) + error_msg = f"A category tag with this {'slug' if error_field == 'category_tag_slug' else 'name'} already exists." + await ack(response_action="errors", errors={error_field: error_msg}) return await ack() diff --git a/nephthys/actions/create_team_tag.py b/nephthys/actions/create_team_tag.py index 3c6a672..40013d3 100644 --- a/nephthys/actions/create_team_tag.py +++ b/nephthys/actions/create_team_tag.py @@ -21,9 +21,17 @@ async def create_team_tag_view_callback( await send_heartbeat(f"Attempted to create tag by non-admin user <@{user_id}>") return - name = body["view"]["state"]["values"]["tag_name"]["tag_name"]["value"] - await env.db.tag.create(data={"name": name}) + values = body["view"]["state"]["values"] + name = values["tag_name"]["tag_name"]["value"] + raw_description = values["tag_description"]["tag_description"]["value"] + description = raw_description.strip() if raw_description else None + + data: dict = {"name": name} + if description: + data["description"] = description + + await env.db.tag.create(data=data) await open_app_home("team-tags", client, user_id) diff --git a/nephthys/actions/edit_category_tag.py b/nephthys/actions/edit_category_tag.py new file mode 100644 index 0000000..7adfa30 --- /dev/null +++ b/nephthys/actions/edit_category_tag.py @@ -0,0 +1,92 @@ +import logging +import re + +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from nephthys.views.modals.edit_category_tag import get_edit_category_tag_modal +from prisma.errors import UniqueViolationError + + +async def edit_category_tag_btn_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + trigger_id = body["trigger_id"] + tag_id = int(body["actions"][0]["value"]) + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await send_heartbeat( + f"Attempted to open edit category tag modal by non-admin user <@{user_id}>" + ) + return + + tag = await env.db.categorytag.find_unique(where={"id": tag_id}) + if not tag: + logging.error(f"Category tag not found: id={tag_id}") + return + + view = get_edit_category_tag_modal(tag.id, tag.name, tag.slug, tag.description) + await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id) + + +async def edit_category_tag_view_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + user_id = body["user"]["id"] + callback_id = body["view"]["callback_id"] + tag_id = int(callback_id.replace("edit_category_tag_", "")) + values = body["view"]["state"]["values"] + + raw_name = values["category_tag_name"]["category_tag_name"]["value"] + name = raw_name.strip() if raw_name else "" + + raw_slug = values["category_tag_slug"]["category_tag_slug"]["value"] + slug = raw_slug.strip() if raw_slug else None + + raw_description = values["category_tag_description"]["category_tag_description"]["value"] + description = raw_description.strip() if raw_description else None + + errors = {} + + if not name: + errors["category_tag_name"] = "Category name cannot be empty." + + if slug and not re.match(r"^[a-z0-9_]+$", slug): + errors["category_tag_slug"] = ( + "Slug must be snake_case (lowercase letters, numbers, and underscores only)." + ) + + if errors: + await ack(response_action="errors", errors=errors) + return + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await ack() + await send_heartbeat( + f"Attempted to edit category tag by non-admin user <@{user_id}>" + ) + return + + try: + await env.db.categorytag.update( + where={"id": tag_id}, + data={"name": name, "slug": slug, "description": description}, + ) + except UniqueViolationError as e: + logging.warning(f"Duplicate category tag: {e}") + error_field = "category_tag_slug" if slug and "slug" in str(e) else "category_tag_name" + error_msg = f"A category tag with this {'slug' if error_field == 'category_tag_slug' else 'name'} already exists." + await ack(response_action="errors", errors={error_field: error_msg}) + return + + await ack() + + from nephthys.events.app_home_opened import open_app_home + + await open_app_home("category-tags", client, user_id) diff --git a/nephthys/actions/edit_team_tag.py b/nephthys/actions/edit_team_tag.py new file mode 100644 index 0000000..6f0006f --- /dev/null +++ b/nephthys/actions/edit_team_tag.py @@ -0,0 +1,65 @@ +import logging + +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from nephthys.views.modals.edit_team_tag import get_edit_team_tag_modal + + +async def edit_team_tag_btn_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + trigger_id = body["trigger_id"] + tag_id = int(body["actions"][0]["value"]) + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await send_heartbeat( + f"Attempted to open edit team tag modal by non-admin user <@{user_id}>" + ) + return + + tag = await env.db.tag.find_unique(where={"id": tag_id}) + if not tag: + logging.error(f"Team tag not found: id={tag_id}") + return + + view = get_edit_team_tag_modal(tag.id, tag.name, tag.description) + await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id) + + +async def edit_team_tag_view_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + callback_id = body["view"]["callback_id"] + tag_id = int(callback_id.replace("edit_team_tag_", "")) + values = body["view"]["state"]["values"] + + name = values["tag_name"]["tag_name"]["value"] + + raw_description = values["tag_description"]["tag_description"]["value"] + description = raw_description.strip() if raw_description else None + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await send_heartbeat( + f"Attempted to edit team tag by non-admin user <@{user_id}>" + ) + return + + await env.db.tag.update( + where={"id": tag_id}, + data={"name": name, "description": description}, + ) + + logging.info(f"Updated team tag id={tag_id} name={name} by <@{user_id}>") + + from nephthys.events.app_home_opened import open_app_home + + await open_app_home("team-tags", client, user_id) diff --git a/nephthys/utils/slack.py b/nephthys/utils/slack.py index edfc29b..98cd8e5 100644 --- a/nephthys/utils/slack.py +++ b/nephthys/utils/slack.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any from typing import Dict @@ -12,6 +13,10 @@ from nephthys.actions.create_category_tag import create_category_tag_btn_callbac from nephthys.actions.create_category_tag import create_category_tag_view_callback from nephthys.actions.create_team_tag import create_team_tag_btn_callback from nephthys.actions.create_team_tag import create_team_tag_view_callback +from nephthys.actions.edit_category_tag import edit_category_tag_btn_callback +from nephthys.actions.edit_category_tag import edit_category_tag_view_callback +from nephthys.actions.edit_team_tag import edit_team_tag_btn_callback +from nephthys.actions.edit_team_tag import edit_team_tag_view_callback from nephthys.actions.resolve import resolve from nephthys.actions.tag_subscribe import tag_subscribe_callback from nephthys.commands.dm_magic_link import dm_magic_link_cmd_callback @@ -108,6 +113,18 @@ async def create_team_tag_view( await create_team_tag_view_callback(ack, body, client) +@app.action(re.compile(r"^edit-team-tag-\d+$")) +async def edit_team_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): + await edit_team_tag_btn_callback(ack, body, client) + + +@app.view(re.compile(r"^edit_team_tag_\d+$")) +async def edit_team_tag_view( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await edit_team_tag_view_callback(ack, body, client) + + @app.action("create-category-tag") async def create_category_tag( ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient @@ -122,6 +139,20 @@ async def create_category_tag_view( await create_category_tag_view_callback(ack, body, client) +@app.action(re.compile(r"^edit-category-tag-\d+$")) +async def edit_category_tag( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await edit_category_tag_btn_callback(ack, body, client) + + +@app.view(re.compile(r"^edit_category_tag_\d+$")) +async def edit_category_tag_view( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await edit_category_tag_view_callback(ack, body, client) + + @app.action("tag-subscribe") async def tag_subscribe(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): await tag_subscribe_callback(ack, body, client) diff --git a/nephthys/views/home/category_tags.py b/nephthys/views/home/category_tags.py index c95c370..2a2ddb5 100644 --- a/nephthys/views/home/category_tags.py +++ b/nephthys/views/home/category_tags.py @@ -31,7 +31,22 @@ async def get_category_tags_view(user: User | None) -> dict: tag_blocks.append(Section(":rac_nooo: no category tags yet — add one below!")) else: for tag in category_tags: - tag_blocks.append(Section(f"*{tag.name}*")) + text = f"*{tag.name}*" + if tag.slug: + text += f"\n`{tag.slug}`" + if tag.description: + text += f"\n_{tag.description}_" + + tag_blocks.append( + Section( + text=text, + accessory=Button( + text=":pencil2: Edit", + action_id=f"edit-category-tag-{tag.id}", + value=str(tag.id), + ), + ) + ) return Home( [ diff --git a/nephthys/views/home/team_tags.py b/nephthys/views/home/team_tags.py index 91d34c1..a82b57c 100644 --- a/nephthys/views/home/team_tags.py +++ b/nephthys/views/home/team_tags.py @@ -37,12 +37,18 @@ async def get_team_tags_view(user: User | None) -> dict: else: subs = [] stringified_subs = [f"<@{user}>" for user in subs] + + tag_text = f"*{tag.name}*" + if tag.description: + tag_text += f"\n_{tag.description}_" + tag_text += f"\n{''.join(stringified_subs) if stringified_subs else ':rac_nooo: no subscriptions'}" + blocks.append( { "type": "section", "text": { "type": "mrkdwn", - "text": f"*{tag.name}* - {''.join(stringified_subs) if stringified_subs else ':rac_nooo: no subscriptions'}", + "text": tag_text, }, "accessory": ( { @@ -62,6 +68,25 @@ async def get_team_tags_view(user: User | None) -> dict: } ) + if is_admin: + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": ":pencil2: Edit tag", + "emoji": True, + }, + "action_id": f"edit-team-tag-{tag.id}", + "value": str(tag.id), + } + ], + } + ) + view = { "type": "home", "blocks": [ diff --git a/nephthys/views/modals/create_category_tag.py b/nephthys/views/modals/create_category_tag.py index 1766f70..8315a94 100644 --- a/nephthys/views/modals/create_category_tag.py +++ b/nephthys/views/modals/create_category_tag.py @@ -14,7 +14,24 @@ def get_create_category_tag_modal(): block_id="category_tag_name", element=PlainTextInput(action_id="category_tag_name"), ), - # future: description - # future: slug + Input( + label="Slug (snake_case ID, optional)", + block_id="category_tag_slug", + optional=True, + element=PlainTextInput( + action_id="category_tag_slug", + placeholder="e.g. payouts_issue or fulfillment_query", + ), + ), + Input( + label="Description (helps AI pick this category)", + block_id="category_tag_description", + optional=True, + element=PlainTextInput( + action_id="category_tag_description", + multiline=True, + placeholder="What kinds of questions should be tagged with this?", + ), + ), ], ).build() diff --git a/nephthys/views/modals/create_team_tag.py b/nephthys/views/modals/create_team_tag.py index 9519665..c96278c 100644 --- a/nephthys/views/modals/create_team_tag.py +++ b/nephthys/views/modals/create_team_tag.py @@ -7,13 +7,18 @@ def get_create_team_tag_modal(): "text": ":rac_info: create a tag!", "emoji": True, }, + "submit": { + "type": "plain_text", + "text": ":rac_question: add tag?", + "emoji": True, + }, "blocks": [ { "type": "input", "block_id": "tag_name", "label": { "type": "plain_text", - "text": "giv name?", + "text": "Name", "emoji": True, }, "element": { @@ -21,10 +26,24 @@ def get_create_team_tag_modal(): "action_id": "tag_name", }, }, + { + "type": "input", + "block_id": "tag_description", + "optional": True, + "label": { + "type": "plain_text", + "text": "Description (optional)", + "emoji": True, + }, + "element": { + "type": "plain_text_input", + "action_id": "tag_description", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What is this tag for?", + }, + }, + }, ], - "submit": { - "type": "plain_text", - "text": ":rac_question: add tag?", - "emoji": True, - }, } diff --git a/nephthys/views/modals/edit_category_tag.py b/nephthys/views/modals/edit_category_tag.py new file mode 100644 index 0000000..d2b253c --- /dev/null +++ b/nephthys/views/modals/edit_category_tag.py @@ -0,0 +1,47 @@ +from blockkit import Input +from blockkit import Modal +from blockkit import PlainTextInput + + +def get_edit_category_tag_modal( + tag_id: int, + name: str, + slug: str | None, + description: str | None, +): + return Modal( + title=":pencil2: edit category", + callback_id=f"edit_category_tag_{tag_id}", + submit=":rac_thumbs: save", + blocks=[ + Input( + label="Category name", + block_id="category_tag_name", + element=PlainTextInput( + action_id="category_tag_name", + initial_value=name, + ), + ), + Input( + label="Slug (snake_case ID, optional)", + block_id="category_tag_slug", + optional=True, + element=PlainTextInput( + action_id="category_tag_slug", + placeholder="e.g. payouts_issue or fulfillment_query", + **({"initial_value": slug} if slug else {}), + ), + ), + Input( + label="Description (helps AI pick this category)", + block_id="category_tag_description", + optional=True, + element=PlainTextInput( + action_id="category_tag_description", + multiline=True, + placeholder="What kinds of questions should be tagged with this?", + **({"initial_value": description} if description else {}), + ), + ), + ], + ).build() diff --git a/nephthys/views/modals/edit_team_tag.py b/nephthys/views/modals/edit_team_tag.py new file mode 100644 index 0000000..ec92be0 --- /dev/null +++ b/nephthys/views/modals/edit_team_tag.py @@ -0,0 +1,36 @@ +def get_edit_team_tag_modal(tag_id: int, name: str, description: str | None): + return { + "type": "modal", + "callback_id": f"edit_team_tag_{tag_id}", + "title": {"type": "plain_text", "text": ":pencil2: edit tag", "emoji": True}, + "submit": {"type": "plain_text", "text": ":rac_thumbs: save", "emoji": True}, + "blocks": [ + { + "type": "input", + "block_id": "tag_name", + "label": {"type": "plain_text", "text": "Name", "emoji": True}, + "element": { + "type": "plain_text_input", + "action_id": "tag_name", + "initial_value": name, + }, + }, + { + "type": "input", + "block_id": "tag_description", + "optional": True, + "label": { + "type": "plain_text", + "text": "Description (optional)", + "emoji": True, + }, + "element": { + "type": "plain_text_input", + "action_id": "tag_description", + "multiline": True, + "placeholder": {"type": "plain_text", "text": "What is this tag for?"}, + **({"initial_value": description} if description else {}), + }, + }, + ], + } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59af15f..81d2f9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,9 +32,9 @@ model User { assignedTickets Ticket[] @relation("AssignedTickets") reopenedTickets Ticket[] @relation("ReopenedTickets") - tagSubscriptions UserTagSubscription[] - createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags") - helper Boolean @default(false) + tagSubscriptions UserTagSubscription[] + createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags") + helper Boolean @default(false) createdAt DateTime @default(now()) } @@ -75,7 +75,7 @@ model Ticket { questionTag QuestionTag? @relation("QuestionTagTickets", fields: [questionTagId], references: [id]) questionTagId Int? - categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id]) + categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id]) categoryTagId Int? createdAt DateTime @default(now()) @@ -90,8 +90,9 @@ model QuestionTag { // These tags are team tags model Tag { - id Int @id @unique @default(autoincrement()) - name String @unique + id Int @id @unique @default(autoincrement()) + name String @unique + description String? ticketsOnTags TagsOnTickets[] @@ -134,11 +135,13 @@ model BotMessage { } model CategoryTag { - id Int @id @unique @default(autoincrement()) - name String @unique - tickets Ticket[] @relation("CategoryTagTickets") + id Int @id @unique @default(autoincrement()) + name String @unique + slug String? @unique + description String? + tickets Ticket[] @relation("CategoryTagTickets") - createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id]) + createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id]) createdById Int? createdAt DateTime @default(now())