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
1 change: 1 addition & 0 deletions app/controllers/admin/audit_log_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion app/controllers/admin/projects_controller.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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:)
Expand Down
7 changes: 5 additions & 2 deletions app/controllers/admin/reviews_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) }
}
Expand Down
103 changes: 103 additions & 0 deletions app/javascript/pages/Admin/Reviews/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RefreshCw,
AlertCircle,
Sparkles,
Send,
} from 'lucide-react'
import ReviewLayout from '@/layouts/ReviewLayout'
import { Badge } from '@/components/admin/ui/badge'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -135,6 +137,7 @@ interface Reviewer {
display_name: string
email: string
is_superadmin: boolean
slack_id: string | null
}

interface ProjectNote {
Expand Down Expand Up @@ -319,6 +322,7 @@ export default function AdminReviewsShow({
notes,
can,
session_stats,
checkpoint_channel_configured,
}: {
project: ReviewProject
session: ReviewSession | null
Expand All @@ -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)
Expand Down Expand Up @@ -379,6 +384,44 @@ export default function AdminReviewsShow({
)
const [overrideJustification, setOverrideJustification] = useState(project.override_hours_justification ?? '')
const [submitting, setSubmitting] = useState<null | 'approve' | 'return' | 'reject' | 'draft'>(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(() => {
Expand Down Expand Up @@ -837,6 +880,17 @@ export default function AdminReviewsShow({
placeholder="What does the builder need to know?"
className="h-20 text-sm"
/>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={openCheckpoint}
disabled={!checkpoint_channel_configured}
title={checkpoint_channel_configured ? undefined : 'FORGE_CHECKPOINT_CHANNEL_ID is not set'}
>
<Send className="size-3.5" />
Send to #forge-checkpoint
</Button>
</div>

<Separator />
Expand Down Expand Up @@ -946,6 +1000,55 @@ export default function AdminReviewsShow({
</AlertDialogContent>
</AlertDialog>
</div>

<AlertDialog open={checkpointOpen} onOpenChange={setCheckpointOpen}>
<AlertDialogContent className="max-w-xl">
<AlertDialogHeader>
<AlertDialogTitle>Send to #forge-checkpoint</AlertDialogTitle>
<AlertDialogDescription>
Posts as the Forge Keeper bot. Edit the body below before sending.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Builder Slack ID</label>
<Input
value={checkpointSlackId}
onChange={(e) => setCheckpointSlackId(e.target.value)}
placeholder="U0123456789"
className="h-8 font-mono text-sm"
/>
{!project.user_slack_id && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
No Slack ID on file for this user, enter one manually.
</p>
)}
</div>
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm whitespace-pre-wrap">
Hey {builderMentionPreview}! Our team of smiths have had a look at your project and here's what we had to say!
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Message body</label>
<Textarea
value={checkpointBody}
onChange={(e) => setCheckpointBody(e.target.value)}
placeholder="What does the builder need to know?"
className="h-32 text-sm"
/>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm whitespace-pre-wrap">
From {reviewerMentionPreview}, please discuss in this thread for any questions/feedback!
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={checkpointSending}>Cancel</AlertDialogCancel>
<Button size="sm" onClick={sendCheckpoint} disabled={checkpointSending}>
{checkpointSending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
post :mark_unbuilt
post :reverse_review
post :ai_requirements_check
post :send_checkpoint_message
delete "notes/:note_id" => "projects#destroy_note", as: :destroy_note
end
end
Expand Down
Loading