diff --git a/app/controllers/admin/api_keys_controller.rb b/app/controllers/admin/api_keys_controller.rb new file mode 100644 index 00000000..65fb4d34 --- /dev/null +++ b/app/controllers/admin/api_keys_controller.rb @@ -0,0 +1,77 @@ +class Admin::ApiKeysController < Admin::ApplicationController + before_action :require_superadmin! + + PROVIDERS = { + "claude" => { + label: "Claude", + credential_source: -> { AiRequirementsChecker.credential_source }, + model: -> { AiRequirementsChecker.model }, + setting_key: AiRequirementsChecker::AUTH_TOKEN_SETTING, + test: ->(token) { AiRequirementsChecker.test_connection!(token) } + }, + "macondo" => { + label: "Macondo", + credential_source: -> { MacondoService.enabled? ? "admin_key" : "none" }, + model: nil, + setting_key: MacondoService::API_KEY_SETTING, + test: ->(token) { MacondoService.test_connection!(token) } + } + }.freeze + + def index + render inertia: "Admin/ApiKeys/Show", props: { + providers: PROVIDERS.map { |id, cfg| provider_payload(id, cfg) } + } + end + + def update + provider = PROVIDERS.fetch(params[:id]) { return not_found! } + token = params[:token].to_s.strip + return redirect_to admin_api_keys_path, alert: "Paste a key first." if token.blank? + + begin + provider[:test].call(token) + rescue StandardError => e + return redirect_to admin_api_keys_path, alert: e.message + end + + AppSetting.set(provider[:setting_key], token) + audit!("#{params[:id]}.reauthed", metadata: { token_digest: Digest::SHA256.hexdigest(token).first(12) }) + redirect_to admin_api_keys_path, notice: "#{provider[:label]} key verified and saved." + end + + def test + provider = PROVIDERS.fetch(params[:id]) { return not_found! } + provider[:test].call(nil) + redirect_to admin_api_keys_path, notice: "#{provider[:label]} connection is working." + rescue StandardError => e + redirect_to admin_api_keys_path, alert: e.message + end + + def destroy + provider = PROVIDERS.fetch(params[:id]) { return not_found! } + AppSetting.clear(provider[:setting_key]) + audit!("#{params[:id]}.token_cleared") + redirect_to admin_api_keys_path, notice: "#{provider[:label]} key cleared." + end + + private + + def not_found! + raise ActionController::RoutingError, "Not Found" + end + + def provider_payload(id, cfg) + { + id: id, + label: cfg[:label], + credential_source: cfg[:credential_source].call, + model: cfg[:model]&.call, + token_saved_at: AppSetting.updated_at_for(cfg[:setting_key])&.strftime("%b %d, %Y %H:%M") + } + end + + def require_superadmin! + require_permission!("superadmin") + end +end diff --git a/app/controllers/admin/claude_controller.rb b/app/controllers/admin/claude_controller.rb deleted file mode 100644 index 1751e636..00000000 --- a/app/controllers/admin/claude_controller.rb +++ /dev/null @@ -1,45 +0,0 @@ -class Admin::ClaudeController < Admin::ApplicationController - before_action :require_superadmin! - - def show - render inertia: "Admin/Claude/Show", props: { - credential_source: AiRequirementsChecker.credential_source, - model: AiRequirementsChecker.model, - token_saved_at: AppSetting.updated_at_for(AiRequirementsChecker::AUTH_TOKEN_SETTING)&.strftime("%b %d, %Y %H:%M") - } - end - - def update - token = params[:token].to_s.strip - return redirect_to admin_claude_path, alert: "Paste a token first." if token.blank? - - begin - AiRequirementsChecker.test_connection!(token) - rescue AiRequirementsChecker::Error => e - return redirect_to admin_claude_path, alert: e.message - end - - AppSetting.set(AiRequirementsChecker::AUTH_TOKEN_SETTING, token) - audit!("claude.reauthed", metadata: { token_digest: Digest::SHA256.hexdigest(token).first(12) }) - redirect_to admin_claude_path, notice: "Claude reauthed — token verified and saved." - end - - def test - AiRequirementsChecker.test_connection! - redirect_to admin_claude_path, notice: "Claude connection is working." - rescue AiRequirementsChecker::Error => e - redirect_to admin_claude_path, alert: e.message - end - - def destroy - AppSetting.clear(AiRequirementsChecker::AUTH_TOKEN_SETTING) - audit!("claude.token_cleared") - redirect_to admin_claude_path, notice: "Saved Claude token cleared." - end - - private - - def require_superadmin! - require_permission!("superadmin") - end -end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 94d2712e..96154c46 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -43,7 +43,8 @@ def new title: "New Project", submit_url: projects_path, method: "post", - hackatime_enabled: HackatimeService.enabled? + hackatime_enabled: HackatimeService.enabled?, + macondo_enabled: MacondoService.enabled? } when Project::BUILD_REVIEW_TIER render inertia: "Projects/Form", props: { @@ -52,7 +53,8 @@ def new submit_url: projects_path, method: "post", linkable_projects: linkable_projects_for(current_user), - hackatime_enabled: HackatimeService.enabled? + hackatime_enabled: HackatimeService.enabled?, + macondo_enabled: MacondoService.enabled? } else render inertia: "Projects/New", props: { @@ -71,6 +73,8 @@ def create if @project.save audit!("project.created", target: @project, metadata: { tier: @project.tier, build_review: @project.build_review }) + macondo_project_id = params.dig(:project, :macondo_project_id).presence + ImportMacondoDataJob.perform_later(@project.id, macondo_project_id) if macondo_project_id redirect_to @project, notice: @project.build_review? ? "Build review created as draft." : "Project created as draft." else fallback_tier = @project.build_review? ? Project::BUILD_REVIEW_TIER : @project.tier @@ -211,6 +215,45 @@ def import_from_github render json: { error: "Could not parse AI response" }, status: :unprocessable_entity end + def import_from_macondo + authorize Project + + unless MacondoService.enabled? + render json: { error: "Macondo import isn't configured." }, status: :service_unavailable + return + end + + project_id = MacondoService.parse_project_id(params[:url]) + if project_id.blank? + render json: { error: "That doesn't look like a Macondo project link." }, status: :unprocessable_entity + return + end + + data = MacondoService.get_project(project_id) + if data.nil? + render json: { error: "Couldn't find that Macondo project." }, status: :not_found + return + end + + unless MacondoService.owned_by?(data, current_user) + render json: { error: "This Macondo project isn't owned by your account." }, status: :forbidden + return + end + + if MacondoService.shipped?(data) + render json: { error: "This project has already been shipped on Macondo and can't be imported." }, status: :unprocessable_entity + return + end + + render json: { + name: data["name"], + description: data["description"], + repo_link: data["repository_url"], + hackatime_projects: Array(data["hackatime_projects"]), + macondo_project_id: project_id + } + end + def add_kudo authorize @project, :show? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index be317011..629538b5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,12 +3,16 @@ module ApplicationHelper ICON_FONT_HREF = "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=block".freeze + # Used to stylize the "Macondo" wordmark on the Import from Macondo button. + DISPLAY_FONTS_HREF = "https://fonts.googleapis.com/css2?family=Are+You+Serious&display=swap".freeze + def google_fonts_tags safe_join([ tag.link(rel: "preconnect", href: "https://fonts.googleapis.com"), tag.link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous"), tag.link(rel: "stylesheet", href: TEXT_FONTS_HREF), - tag.link(rel: "stylesheet", href: ICON_FONT_HREF) + tag.link(rel: "stylesheet", href: ICON_FONT_HREF), + tag.link(rel: "stylesheet", href: DISPLAY_FONTS_HREF) ], "\n") end diff --git a/app/javascript/components/admin/AdminSidebar.tsx b/app/javascript/components/admin/AdminSidebar.tsx index ef4ba597..f7556fc9 100644 --- a/app/javascript/components/admin/AdminSidebar.tsx +++ b/app/javascript/components/admin/AdminSidebar.tsx @@ -184,7 +184,7 @@ function buildSections(): { items: NavItem[] }[] { { label: 'Review Audits', href: '/admin/review_audits', icon: Activity, permission: 'is_superadmin' }, { label: 'Metrics', href: '/admin/metrics', icon: BarChart3, permission: 'audit_log' }, { label: 'Database', href: '/admin/database', icon: Database, permission: 'is_admin' }, - { label: 'Claude', href: '/admin/claude', icon: Bot, permission: 'is_superadmin' }, + { label: 'API Keys', href: '/admin/api_keys', icon: Bot, permission: 'is_superadmin' }, { label: 'Airtable Queue', href: '/admin/airtable_queue', icon: TableProperties, permission: 'is_superadmin' }, { label: 'Jobs', href: '/admin/jobs', icon: Briefcase, external: true, permission: 'jobs' }, { label: 'Sentry', href: 'https://sentry.io', icon: Activity, external: true, permission: 'third_party' }, diff --git a/app/javascript/pages/Admin/ApiKeys/Show.tsx b/app/javascript/pages/Admin/ApiKeys/Show.tsx new file mode 100644 index 00000000..5709eba4 --- /dev/null +++ b/app/javascript/pages/Admin/ApiKeys/Show.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { router } from '@inertiajs/react' +import { KeyRound, PlugZap, Trash2 } from 'lucide-react' +import { Button } from '@/components/admin/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/admin/ui/card' +import { Input } from '@/components/admin/ui/input' +import { Badge } from '@/components/admin/ui/badge' + +interface Provider { + id: string + label: string + credential_source: string + model: string | null + token_saved_at: string | null +} + +const SOURCE_LABELS: Record = { + env_api_key: { label: 'API key (env)', variant: 'success' }, + admin_token: { label: 'OAuth token (saved here)', variant: 'success' }, + admin_key: { label: 'Key (saved here)', variant: 'success' }, + env_auth_token: { label: 'OAuth token (env)', variant: 'secondary' }, + none: { label: 'Not configured', variant: 'destructive' }, +} + +function ProviderCard({ provider }: { provider: Provider }) { + const [token, setToken] = useState('') + const [saving, setSaving] = useState(false) + const source = SOURCE_LABELS[provider.credential_source] ?? SOURCE_LABELS.none + + function reauth(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + router.post( + `/admin/api_keys/${provider.id}`, + { token }, + { + onFinish: () => { + setSaving(false) + setToken('') + }, + }, + ) + } + + function testConnection() { + router.post(`/admin/api_keys/${provider.id}/test`) + } + + function clearToken() { + if (!confirm(`Clear the saved ${provider.label} key?`)) return + router.delete(`/admin/api_keys/${provider.id}`) + } + + return ( + + + {provider.label} + + + +
+
+ Credentials + {source.label} +
+ {provider.model && ( +
+ Model + {provider.model} +
+ )} + {provider.token_saved_at && ( +
+ Key last saved + {provider.token_saved_at} +
+ )} +
+ +
+
+ + setToken(e.target.value)} + placeholder="Paste a new key…" + autoComplete="off" + required + /> +

+ Verified before it's saved, then used for all {provider.label} requests. Stored encrypted and never shown + again. +

+
+
+ + {provider.credential_source !== 'none' && provider.credential_source !== 'env_api_key' && ( + + )} +
+
+
+
+ ) +} + +export default function AdminApiKeysShow({ providers }: { providers: Provider[] }) { + return ( +
+

API Keys

+ + {providers.map((provider) => ( + + ))} +
+ ) +} diff --git a/app/javascript/pages/Admin/Claude/Show.tsx b/app/javascript/pages/Admin/Claude/Show.tsx deleted file mode 100644 index 11913053..00000000 --- a/app/javascript/pages/Admin/Claude/Show.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from 'react' -import { router } from '@inertiajs/react' -import { KeyRound, PlugZap, Trash2 } from 'lucide-react' -import { Button } from '@/components/admin/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/admin/ui/card' -import { Input } from '@/components/admin/ui/input' -import { Badge } from '@/components/admin/ui/badge' - -interface Props { - credential_source: 'env_api_key' | 'admin_token' | 'env_auth_token' | 'none' - model: string - token_saved_at: string | null -} - -const SOURCE_LABELS: Record< - Props['credential_source'], - { label: string; variant: 'success' | 'secondary' | 'destructive' } -> = { - env_api_key: { label: 'API key (env)', variant: 'success' }, - admin_token: { label: 'OAuth token (saved here)', variant: 'success' }, - env_auth_token: { label: 'OAuth token (env)', variant: 'secondary' }, - none: { label: 'Not configured', variant: 'destructive' }, -} - -export default function AdminClaudeShow({ credential_source, model, token_saved_at }: Props) { - const [token, setToken] = useState('') - const [saving, setSaving] = useState(false) - const source = SOURCE_LABELS[credential_source] - - function reauth(e: React.FormEvent) { - e.preventDefault() - setSaving(true) - router.post( - '/admin/claude', - { token }, - { - onFinish: () => { - setSaving(false) - setToken('') - }, - }, - ) - } - - function testConnection() { - router.post('/admin/claude/test') - } - - function clearToken() { - if (!confirm('Clear the saved Claude token? Checks fall back to env credentials, if any.')) return - router.delete('/admin/claude') - } - - return ( -
-
-

Claude

- -
- - - - Status - - -
- Credentials - {source.label} -
-
- Model - {model} -
- {token_saved_at && ( -
- Token last saved - {token_saved_at} -
- )} -
-
- - - - Reauth Claude - - -
-
- - setToken(e.target.value)} - placeholder="sk-ant-oat01-..." - autoComplete="off" - required - /> -

- The token is verified against the Claude API before it's saved, then used for all AI checks. It's stored - encrypted and never shown again. -

-
-
- - {credential_source === 'admin_token' && ( - - )} -
-
-
-
-
- ) -} diff --git a/app/javascript/pages/Projects/Form.tsx b/app/javascript/pages/Projects/Form.tsx index 07f72866..ecd35fc9 100644 --- a/app/javascript/pages/Projects/Form.tsx +++ b/app/javascript/pages/Projects/Form.tsx @@ -4,6 +4,29 @@ import type { ProjectForm, ProjectTier, SharedProps } from '@/types' import { tierCoinRate } from '@/lib/tiers' import HackatimePicker from '@/components/HackatimePicker' +function GitHubMark({ className }: { className?: string }) { + return ( + + ) +} + +function MacondoWordmark({ className }: { className?: string }) { + return ( + + Macondo + + ) +} + export default function ProjectsForm({ project, title, @@ -11,6 +34,7 @@ export default function ProjectsForm({ method, linkable_projects, hackatime_enabled, + macondo_enabled, }: { project: ProjectForm title: string @@ -18,12 +42,17 @@ export default function ProjectsForm({ method: string linkable_projects?: { id: number; name: string }[] hackatime_enabled?: boolean + macondo_enabled?: boolean }) { const { errors } = usePage().props const [importUrl, setImportUrl] = useState('') const [importing, setImporting] = useState(false) const [importError, setImportError] = useState('') const [showImport, setShowImport] = useState(false) + const [macondoUrl, setMacondoUrl] = useState('') + const [macondoImporting, setMacondoImporting] = useState(false) + const [macondoError, setMacondoError] = useState('') + const [showMacondoImport, setShowMacondoImport] = useState(false) const isBuildReview = project.tier === 'tier_build_review' || !!project.build_review const form = useForm({ @@ -37,6 +66,7 @@ export default function ProjectsForm({ uses_ai: project.uses_ai, ai_usage: project.ai_usage, hackatime_projects: project.hackatime_projects ?? [], + macondo_project_id: '', }) async function handleImport() { @@ -87,6 +117,53 @@ export default function ProjectsForm({ } } + async function handleMacondoImport() { + setMacondoError('') + + if (!macondoUrl.match(/macondo\.hackclub\.com\/projects\/[^/?#]+/)) { + setMacondoError('Enter a valid Macondo project URL') + return + } + + setMacondoImporting(true) + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + const res = await fetch('/projects/import_from_macondo', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), + }, + body: JSON.stringify({ url: macondoUrl }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || 'Import failed') + } + + const data = await res.json() + + form.setData({ + ...form.data, + name: data.name || form.data.name, + repo_link: data.repo_link || form.data.repo_link, + hackatime_projects: Array.isArray(data.hackatime_projects) + ? data.hackatime_projects + : form.data.hackatime_projects, + macondo_project_id: data.macondo_project_id || '', + }) + + setShowMacondoImport(false) + setMacondoUrl('') + } catch (err) { + setMacondoError(err instanceof Error ? err.message : 'Could not import project.') + } finally { + setMacondoImporting(false) + } + } + function submit(e: React.FormEvent) { e.preventDefault() if (method === 'patch') { @@ -100,21 +177,40 @@ export default function ProjectsForm({

{title}

- {method === 'post' && !showImport && !isBuildReview && ( - + {method === 'post' && !isBuildReview && (!showImport || (macondo_enabled && !showMacondoImport)) && ( +
+ {!showImport && ( + + )} + {macondo_enabled && !showMacondoImport && ( + + )} +
)} {showImport && (
- Import from GitHub + + + Import from GitHub +
)} + {showMacondoImport && ( +
+
+ + Import from + + +
+
+ setMacondoUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleMacondoImport())} + className="flex-1 bg-[#0e0e0e] border-none px-4 py-3 text-[#e5e2e1] focus:ring-1 focus:ring-[#ca5924]/30 placeholder:text-stone-600 text-sm min-w-0" + placeholder="https://macondo.hackclub.com/projects/..." + /> + +
+

Open your project in a new tab and copy that URL

+ {macondoError && ( +

+ error + {macondoError} +

+ )} +
+ )} +
{Object.keys(errors).length > 0 && (
diff --git a/app/jobs/import_macondo_data_job.rb b/app/jobs/import_macondo_data_job.rb new file mode 100644 index 00000000..9a35608f --- /dev/null +++ b/app/jobs/import_macondo_data_job.rb @@ -0,0 +1,44 @@ +require "open-uri" + +class ImportMacondoDataJob < ApplicationJob + queue_as :default + + def perform(project_id, macondo_project_id) + project = Project.find_by(id: project_id) + return unless project + + data = MacondoService.get_project(macondo_project_id) + return unless data + return unless MacondoService.owned_by?(data, project.user) + return if MacondoService.shipped?(data) + + attach_cover_image(project, data["thumbnail_url"]) if data["thumbnail_url"].present? + import_journals(project, data["journals"].presence || MacondoService.get_journals(macondo_project_id)) + end + + private + + def attach_cover_image(project, url) + io = URI.open(url) + filename = File.basename(URI.parse(url).path.presence || "cover.png") + project.cover_image.attach(io: io, filename: filename) + rescue StandardError => e + Rails.logger.error("ImportMacondoDataJob cover image failed: #{e.message}") + end + + def import_journals(project, journals) + Array(journals).each do |j| + title = j["short_brief"].presence || "Imported entry" + next if project.devlogs.exists?(title: title) + + project.devlogs.create!( + title: title, + content: j["long_brief"].presence || j["short_brief"].presence || "Imported from Macondo.", + time_hours: j["hours"], + entry_date: j["created_at"]&.to_date + ) + end + rescue StandardError => e + Rails.logger.error("ImportMacondoDataJob journals failed: #{e.message}") + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 71ebd073..50210764 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -17,6 +17,10 @@ def import_from_github? user.present? end + def import_from_macondo? + user.present? + end + def hackatime_projects? user.present? end diff --git a/app/services/macondo_service.rb b/app/services/macondo_service.rb new file mode 100644 index 00000000..8121a184 --- /dev/null +++ b/app/services/macondo_service.rb @@ -0,0 +1,80 @@ +require "faraday" +require "json" + +module MacondoService + module_function + + API_KEY_SETTING = "macondo_api_key" + BASE_URL = "https://macondo.hackclub.com/api" + URL_PATTERN = %r{macondo\.hackclub\.com/projects/([a-zA-Z0-9_-]+)} + + def api_key + AppSetting.get(API_KEY_SETTING) + end + + def enabled? + api_key.present? + end + + def parse_project_id(url) + url.to_s[URL_PATTERN, 1] + end + + def get_project(id) + return nil unless enabled? && id.present? + + response = connection.get("projects/#{id}") + return nil unless response.success? + + JSON.parse(response.body) + rescue StandardError => e + Rails.logger.error("MacondoService.get_project failed: #{e.message}") + nil + end + + def get_journals(id) + return [] unless enabled? && id.present? + + response = connection.get("projects/#{id}/journals") + return [] unless response.success? + + JSON.parse(response.body) + rescue StandardError => e + Rails.logger.error("MacondoService.get_journals failed: #{e.message}") + [] + end + + def shipped?(project_json) + project_json["hasPreviousShippedShip"] == true || project_json["activeShip"].present? + end + + # The Get Project response includes an `owner` object; match it against the + # Forge user attempting the import so nobody can import someone else's project. + def owned_by?(project_json, user) + owner = project_json["owner"] + return false if owner.blank? || user.nil? + + owner["slack_id"].present? && owner["slack_id"] == user.slack_id + end + + def test_connection!(token_override = nil) + key = token_override.presence || api_key + raise "No Macondo key configured." if key.blank? + + response = Faraday.new(url: BASE_URL).get("explore/projects") do |req| + req.headers["Authorization"] = "Bearer #{key}" + req.params[:limit] = 1 + end + raise "Macondo rejected the key (#{response.status})." unless response.success? + end + + def connection + @connection = nil if Rails.env.test? + @connection ||= Faraday.new(url: BASE_URL) do |f| + f.headers["Authorization"] = "Bearer #{api_key}" + f.adapter Faraday.default_adapter + f.options.timeout = 10 + f.options.open_timeout = 5 + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 3dabcde3..a3e7faae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ # admin_tier_reviews GET /admin/reviews/:tier(.:format) admin/reviews#index {tier: /t[1-4]/} # admin_reviews_leaderboard GET /admin/reviews/leaderboard(.:format) admin/reviews#leaderboard # admin_requirements_reviews GET /admin/reviews/requirements(.:format) admin/reviews#requirements +# admin_flagged_reviews GET /admin/reviews/flagged(.:format) admin/reviews#flagged # admin_review GET /admin/reviews/:id(.:format) admin/reviews#show # admin_skip_review POST /admin/reviews/:id/skip(.:format) admin/reviews#skip # admin_track_review POST /admin/reviews/:id/track(.:format) admin/reviews#track @@ -124,10 +125,10 @@ # admin_audit_log_entry GET /admin/audit_log/:id(.:format) admin/audit_log#show # admin_database GET /admin/database(.:format) admin/database#index # admin_database_query POST /admin/database/query(.:format) admin/database#query -# admin_claude GET /admin/claude(.:format) admin/claude#show -# POST /admin/claude(.:format) admin/claude#update -# admin_claude_test POST /admin/claude/test(.:format) admin/claude#test -# DELETE /admin/claude(.:format) admin/claude#destroy +# admin_api_keys GET /admin/api_keys(.:format) admin/api_keys#index +# admin_api_key POST /admin/api_keys/:id(.:format) admin/api_keys#update +# admin_api_key_test POST /admin/api_keys/:id/test(.:format) admin/api_keys#test +# admin DELETE /admin/api_keys/:id(.:format) admin/api_keys#destroy # reply_admin_support_ticket POST /admin/support/:id/reply(.:format) admin/support_tickets#reply # claim_admin_support_ticket POST /admin/support/:id/claim(.:format) admin/support_tickets#claim # resolve_admin_support_ticket POST /admin/support/:id/resolve(.:format) admin/support_tickets#resolve @@ -188,6 +189,7 @@ # PUT /reels/:id(.:format) reels#update # DELETE /reels/:id(.:format) reels#destroy # import_from_github_projects POST /projects/import_from_github(.:format) projects#import_from_github +# import_from_macondo_projects POST /projects/import_from_macondo(.:format) projects#import_from_macondo # hackatime_projects_projects GET /projects/hackatime_projects(.:format) projects#hackatime_projects # submit_for_review_project POST /projects/:id/submit_for_review(.:format) projects#submit_for_review # ai_check_project GET /projects/:id/ai_check(.:format) projects#ai_check @@ -436,10 +438,10 @@ get "audit_log/:id" => "audit_log#show", as: :audit_log_entry get "database" => "database#index", as: :database post "database/query" => "database#query" - get "claude" => "claude#show", as: :claude - post "claude" => "claude#update" - post "claude/test" => "claude#test" - delete "claude" => "claude#destroy" + get "api_keys" => "api_keys#index", as: :api_keys + post "api_keys/:id" => "api_keys#update", as: :api_key + post "api_keys/:id/test" => "api_keys#test", as: :api_key_test + delete "api_keys/:id" => "api_keys#destroy" resources :support_tickets, only: [ :index, :show, :destroy ], path: "support" do member do post :reply @@ -515,6 +517,7 @@ resources :projects, except: :index do collection do post :import_from_github + post :import_from_macondo get :hackatime_projects end member do