diff --git a/app/controllers/admin/audit_log_controller.rb b/app/controllers/admin/audit_log_controller.rb index b9de6b8..1e56e91 100644 --- a/app/controllers/admin/audit_log_controller.rb +++ b/app/controllers/admin/audit_log_controller.rb @@ -100,6 +100,7 @@ def target_url_for(event) "project.note_added" => "Added internal note on", "project.note_destroyed" => "Deleted internal note from", "project.readme_refreshed" => "Refreshed README for", + "project.checkpoint_message_sent" => "Sent checkpoint message for", "project.visibility_toggled" => "Toggled visibility on", "project.staff_pick_toggled" => "Toggled staff pick on", "project.restored" => "Restored project", diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 4a2dc33..59a5184 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -1,6 +1,6 @@ class Admin::ProjectsController < Admin::ApplicationController before_action :require_projects_permission! - before_action :set_project, only: [ :show, :review, :destroy, :restore, :toggle_hidden, :toggle_staff_pick, :change_tier, :add_note, :destroy_note, :mark_unbuilt, :reverse_review, :ai_requirements_check ] + before_action :set_project, only: [ :show, :review, :destroy, :restore, :toggle_hidden, :toggle_staff_pick, :change_tier, :add_note, :destroy_note, :mark_unbuilt, :reverse_review, :ai_requirements_check, :send_checkpoint_message ] def index scope = policy_scope(Project).includes(:user, :ships) @@ -384,6 +384,40 @@ def ai_requirements_check redirect_back fallback_location: admin_review_path(@project), alert: "AI check failed: #{e.message}" end + def send_checkpoint_message + authorize @project, :review? + + channel = ENV["FORGE_CHECKPOINT_CHANNEL_ID"].to_s.strip + if channel.blank? + redirect_back fallback_location: admin_review_path(@project), alert: "FORGE_CHECKPOINT_CHANNEL_ID is not set." + return + end + + body = params[:body].to_s.strip + user_slack_id = params[:user_slack_id].to_s.strip + if body.blank? + redirect_back fallback_location: admin_review_path(@project), alert: "Message body is required." + return + end + if user_slack_id.blank? + redirect_back fallback_location: admin_review_path(@project), alert: "Builder Slack ID is required." + return + end + + user_mention = "<@#{user_slack_id}>" + reviewer_mention = current_user.slack_id.present? ? "<@#{current_user.slack_id}>" : current_user.display_name + + text = "Hey #{user_mention}! Our team of smiths have had a look at your project and here's what we had to say!\n\n#{body}\n\nFrom #{reviewer_mention}, please discuss in this thread for any questions/feedback!" + + client = Slack::Web::Client.new(token: ENV.fetch("SLACK_BOT_TOKEN", nil)) + client.chat_postMessage(channel: channel, text: text) + + audit!("project.checkpoint_message_sent", target: @project, metadata: { user_slack_id: user_slack_id }) + redirect_back fallback_location: admin_review_path(@project), notice: "Checkpoint message sent." + rescue Slack::Web::Api::Errors::SlackError => e + redirect_back fallback_location: admin_review_path(@project), alert: "Slack error: #{e.message}" + end + private def end_active_review_session(decision:) diff --git a/app/controllers/admin/reviews_controller.rb b/app/controllers/admin/reviews_controller.rb index 4521b28..fbf3ef9 100644 --- a/app/controllers/admin/reviews_controller.rb +++ b/app/controllers/admin/reviews_controller.rb @@ -35,10 +35,12 @@ def show reviewer: { display_name: current_user.display_name, email: current_user.email, - is_superadmin: current_user.superadmin? + is_superadmin: current_user.superadmin?, + slack_id: current_user.slack_id }, can: { review: policy(@project).review? }, - session_stats: current_user.superadmin? ? session_stats(@project) : nil + session_stats: current_user.superadmin? ? session_stats(@project) : nil, + checkpoint_channel_configured: ENV["FORGE_CHECKPOINT_CHANNEL_ID"].to_s.strip.present? } end @@ -174,6 +176,7 @@ def serialize_project_for_review(project) user_display_name: project.user.display_name, user_email: project.user.email, user_avatar: project.user.avatar, + user_slack_id: project.user.slack_id, coins_earned_preview: project.coin_rate.to_f * project.total_hours.to_f, devlogs: project.devlogs.order(created_at: :asc).map { |d| serialize_devlog(d) } } diff --git a/app/javascript/pages/Admin/Reviews/Show.tsx b/app/javascript/pages/Admin/Reviews/Show.tsx index 44e9855..424240c 100644 --- a/app/javascript/pages/Admin/Reviews/Show.tsx +++ b/app/javascript/pages/Admin/Reviews/Show.tsx @@ -24,6 +24,7 @@ import { RefreshCw, AlertCircle, Sparkles, + Send, } from 'lucide-react' import ReviewLayout from '@/layouts/ReviewLayout' import { Badge } from '@/components/admin/ui/badge' @@ -98,6 +99,7 @@ interface ReviewProject { user_id: number user_display_name: string user_email: string + user_slack_id: string | null user_avatar: string coins_earned_preview: number devlogs: { @@ -135,6 +137,7 @@ interface Reviewer { display_name: string email: string is_superadmin: boolean + slack_id: string | null } interface ProjectNote { @@ -319,6 +322,7 @@ export default function AdminReviewsShow({ notes, can, session_stats, + checkpoint_channel_configured, }: { project: ReviewProject session: ReviewSession | null @@ -329,6 +333,7 @@ export default function AdminReviewsShow({ notes: ProjectNote[] can: { review: boolean } session_stats: SessionStats | null + checkpoint_channel_configured: boolean }) { const isTerminal = project.status !== 'pending' useReviewHeartbeat(session?.heartbeat_path ?? null, session?.active_seconds ?? 0) @@ -379,6 +384,44 @@ export default function AdminReviewsShow({ ) const [overrideJustification, setOverrideJustification] = useState(project.override_hours_justification ?? '') const [submitting, setSubmitting] = useState(null) + const [checkpointOpen, setCheckpointOpen] = useState(false) + const [checkpointBody, setCheckpointBody] = useState('') + const [checkpointSlackId, setCheckpointSlackId] = useState(project.user_slack_id ?? '') + const [checkpointSending, setCheckpointSending] = useState(false) + + const openCheckpoint = useCallback(() => { + setCheckpointBody(feedback) + setCheckpointSlackId(project.user_slack_id ?? '') + setCheckpointOpen(true) + }, [feedback, project.user_slack_id]) + + const sendCheckpoint = useCallback(() => { + const body = checkpointBody.trim() + const slackId = checkpointSlackId.trim() + if (!body) { + alert('Message body is required.') + return + } + if (!slackId) { + alert('Builder Slack ID is required.') + return + } + setCheckpointSending(true) + router.post( + `/admin/projects/${project.id}/send_checkpoint_message`, + { body, user_slack_id: slackId }, + { + preserveScroll: true, + onFinish: () => { + setCheckpointSending(false) + setCheckpointOpen(false) + }, + }, + ) + }, [project.id, checkpointBody, checkpointSlackId]) + + const reviewerMentionPreview = reviewer.slack_id ? `<@${reviewer.slack_id}>` : reviewer.display_name + const builderMentionPreview = checkpointSlackId.trim() ? `<@${checkpointSlackId.trim()}>` : '<@?>' const claimedHours = project.devlog_hours const approvedHours = useMemo(() => { @@ -837,6 +880,17 @@ export default function AdminReviewsShow({ placeholder="What does the builder need to know?" className="h-20 text-sm" /> + @@ -946,6 +1000,55 @@ export default function AdminReviewsShow({ + + + + + Send to #forge-checkpoint + + Posts as the Forge Keeper bot. Edit the body below before sending. + + +
+
+ + setCheckpointSlackId(e.target.value)} + placeholder="U0123456789" + className="h-8 font-mono text-sm" + /> + {!project.user_slack_id && ( +

+ No Slack ID on file for this user, enter one manually. +

+ )} +
+
+ Hey {builderMentionPreview}! Our team of smiths have had a look at your project and here's what we had to say! +
+
+ +