Handoff message for admin_portal Claude
What this is: A bootstrap message to paste into a Claude session running in the
admin.gatewaygeo.ioDjango repo. Replaces Jotform + Zapier with an in-house application pipeline. The static-site form is committed (unpushed) in this repo atpages/job-application-form.html; the Django endpoint described below is what needs to be built on the other side.Full design spec:
docs/superpowers/specs/2026-05-11-applications-system-design.mdin this repo. Paste it if the admin Claude asks for the long-form version.Cutover discipline: The static-site form is intentionally unpushed. Hold pushing this repo until the admin endpoint is ready, so live applicants don’t hit “Network error” during the gap.
Hi —
I’m Claude running in the gatewaygeospatial.github.io static-site repo. James and I have been redesigning the job application pipeline together. He’s relaying between us; treat me as a peer engineer working on the other side of an API contract.
What we’re doing
Retiring Jotform + Zapier. Replacing with: • An in-house HTML form on the static site (gatewaygeo.io) — I’ve built this already; it’s committed locally but unpushed • A new Django endpoint on admin.gatewaygeo.io that you’ll build • Applications stored as a new Application model in the admin DB, with FK to your existing Job model • Three best-effort side-effects fanning out from each submission: SharePoint upload, SES email to admin@gatewaygeo.io, Slack webhook
The full design spec lives in our static-site repo at
docs/superpowers/specs/2026-05-11-applications-system-design.md.
James can paste it if you need the long-form version. The most important
parts are below.
What I’ve built (static side)
A real HTML form at /pages/job-application-form/?jobNumber=JOB-2026-0088
that POSTs multipart/form-data to:
https://admin.gatewaygeo.io/api/applications/ (production)
http://localhost:8000/api/applications/ (when on localhost)
The form fields (multipart form-data, NOT JSON body):
name string, required, max 200 email string, required, valid email phone string, required, max 20 job_number string, required — the JOB-2026-0088 style ID; you look up the Job row by this clearance string, required, one of 8 enum values (list below) ideal_role string, optional, max 2000 chars resume file, required, .pdf or .docx, max 10 MB website string, the honeypot — if non-empty, silently drop (return 200 success but do not save to DB, do not fire side-effects). Do not reveal that it’s a trap.
The 8 clearance enum values (exact strings — both stored and rendered):
none → “None” public_trust → “Public Trust” secret → “Secret” ts → “Top Secret (TS)” ts_sci → “TS/SCI” ts_sci_ci → “TS/SCI with CI Polygraph” ts_sci_fs → “TS/SCI with Full-Scope Polygraph” decline → “Decline to answer”
What I need from the endpoint
Response shape (JSON in all cases):
Success: HTTP 200 { “success”: true, “application_id”: 123, “message”: “Application received.” }
Validation errors: HTTP 400 { “success”: false, “errors”: { “email”: [“Enter a valid email address.”], “resume”: [“File must be .pdf or .docx.”], “clearance”: [“Select a valid choice.”] } }
Unknown job_number: HTTP 404 { “success”: false, “errors”: { “job_number”: [“Unknown position.”] } }
File too large (>10 MB): HTTP 413 { “success”: false, “errors”: { “resume”: [“File too large. Maximum 10 MB.”] } }
Rate limited (5 successful submissions per hour, per IP): HTTP 429 { “success”: false, “errors”: { “_global”: [“Too many submissions. Try again later.”] } }
Server error: HTTP 500 { “success”: false, “errors”: { “_global”: [“Something went wrong. Please email admin@gatewaygeo.io.”] } }
My JS reads result.errors[field_name] for each form field and renders the
message inline. The _global key surfaces in a general error box. The
field-name format matches your Django form field names exactly.
CORS: please allow POST + OPTIONS from these origins:
https://gatewaygeo.io (production static site) http://localhost:4321 (my Jekyll dev server) http://127.0.0.1:4321 (same)
Access-Control-Allow-Headers: Content-Type
django-cors-headers package handles this cleanly.
Data model sketch
(You’ll know best where this fits in your codebase — probably alongside the Job model in whatever app owns jobs. Use this as the contract:)
class Application(models.Model):
CLEARANCE_CHOICES = [
('none', 'None'),
('public_trust', 'Public Trust'),
('secret', 'Secret'),
('ts', 'Top Secret (TS)'),
('ts_sci', 'TS/SCI'),
('ts_sci_ci', 'TS/SCI with CI Polygraph'),
('ts_sci_fs', 'TS/SCI with Full-Scope Polygraph'),
('decline', 'Decline to answer'),
]
job = models.ForeignKey('jobs.Job', on_delete=models.PROTECT,
related_name='applications')
name = models.CharField(max_length=200)
email = models.EmailField()
phone = models.CharField(max_length=20)
clearance = models.CharField(max_length=20, choices=CLEARANCE_CHOICES)
ideal_role = models.TextField(blank=True)
resume = models.FileField(upload_to=resume_upload_path,
validators=[validate_resume_file])
applied_at = models.DateTimeField(auto_now_add=True, db_index=True)
sharepoint_url = models.URLField(blank=True, default='')
sharepoint_uploaded_at = models.DateTimeField(null=True, blank=True)
email_sent_at = models.DateTimeField(null=True, blank=True)
slack_notified_at = models.DateTimeField(null=True, blank=True)
failure_log = models.TextField(blank=True, default='')
class Meta:
ordering = ['-applied_at']
indexes = [models.Index(fields=['job', '-applied_at'])]
PROTECT on the FK is intentional — we don’t want a Job delete to silently nuke applicant records.
The four side-effect timestamps capture WHEN each side-effect succeeded.
NULL means “didn’t happen / failed”; the failure_log TextField holds
line-delimited human-readable error messages from any side-effect failures.
Admin integration
The big requirement James called out: when he opens a Job in your Django
admin, he wants to see “Applicants” inline showing who applied. The
related_name='applications' on the FK enables this:
class ApplicationInline(admin.TabularInline):
model = Application
extra = 0
fields = readonly_fields = ['name', 'email', 'phone', 'clearance', 'applied_at']
can_delete = False
# Add inlines=[ApplicationInline] to your existing JobAdmin
@admin.register(Application)
class ApplicationAdmin(admin.ModelAdmin):
list_display = ['name', 'email', 'job', 'clearance', 'applied_at',
'sharepoint_status', 'email_status']
list_filter = ['clearance', 'applied_at', 'job']
search_fields = ['name', 'email', 'job__title', 'job__job_number']
date_hierarchy = 'applied_at'
readonly_fields = ['applied_at', 'sharepoint_url', 'sharepoint_uploaded_at',
'email_sent_at', 'slack_notified_at']
def sharepoint_status(self, obj):
return '✓' if obj.sharepoint_uploaded_at else '—'
def email_status(self, obj):
return '✓' if obj.email_sent_at else '—'
Side-effect orchestration (run sync, in this order, after DB write)
- SharePoint upload (Microsoft Graph)
- Target:
gatewaygeospatialgroupllc.sharepoint.com - Site:
ResumeDepot - Document library:
Applied Directly - Folder:
JOB-{job_number}/— create if absent (idempotent, 409 = “exists” = success) - Filename:
Lastname_Firstname_YYYY-MM-DD_app{id}.{pdf|docx} - On success: store the resulting webUrl in
Application.sharepoint_url, stampApplication.sharepoint_uploaded_at = timezone.now() - On failure: append a line to
Application.failure_log, log to Django logger, continue to next step. DO NOT return error to user.
- Target:
- SES email (boto3
send_raw_email— must support resume attachment)- From:
noreply@gatewaygeo.io(SES verified sender — please confirm) - To:
admin@gatewaygeo.io - Reply-To:
{applicant.email} - Subject:
"New application: {name} → {job.title} ({job.job_number})" - Body (plain text): form fields + applied_at + admin URL to the row
- Attachment: the raw resume file with its original filename
- This email IS the human-readable application — it replaces what Zapier was doing today. Keep it complete and self-contained.
- From:
- Slack webhook (POST to env:
SLACK_APPLICATIONS_WEBHOOK_URL)- Block Kit JSON with name, position, clearance, email, and a button linking to
/admin/applications/{id}/. James will provide the actual webhook URL when you ask.
- Block Kit JSON with name, position, clearance, email, and a button linking to
All three are “fire and log on failure” — the user always gets 200 success as long as the DB write succeeded. No retries inside the request. Out-of-band retry via a Django management command if needed.
Rate limiting
5 successful submissions per hour, per IP. Use django-ratelimit:
@ratelimit(key='ip', rate='5/h', method='POST', block=True)
def application_create_view(request):
...
Honeypot hits and validation failures DON’T count toward the limit (use the silent-drop pattern for honeypot before the rate-limit decorator checks anything).
Testing
Mock SharePoint client, boto3 SES client, and the Slack webhook (requests.post)
in your tests. The spec calls for these integration tests at minimum:
• Valid POST → 200, Application row exists, three side-effect calls • Missing required field → 400 with field-specific error • Unknown job_number → 404 • Honeypot non-empty → 200, no DB row, no side-effects called • File >10 MB → 413 • 6th submission from same IP within an hour → 429 • SharePoint mock raises → 200 to user, DB row exists, failure_log populated, sharepoint_uploaded_at IS NULL, email + Slack still called • SES mock raises → same shape • Slack mock raises → same shape
Coverage target: 80%+ on the new app.
Discovery questions
These came up during design that James doesn’t know offhand and I can’t see from my repo. Please answer these as you investigate your codebase, and relay back what you find:
-
Which Microsoft Graph library is already in use for the existing SharePoint integration? (
msal?Office365-REST-Python-Client? Something custom?) Affects how you write the upload code. -
What’s the app name that owns the Job model? I assumed
jobsin the FK string'jobs.Job'— confirm or correct. -
Is
noreply@gatewaygeo.io(or some other ‘noreply’ sender) verified in SES already, or does that need setup? -
Does your existing settings have a
MEDIA_ROOT/MEDIA_URLconfigured for Django’s FileField storage? The resume FileField needs storage — if you’re already on django-storages/S3 for any existing FileField, reuse it. If nothing exists, local MEDIA_ROOT is fine for v1 since the resume also goes to SharePoint immediately. -
Which Slack channel webhook should fire? Reuse the existing RSVP webhook, or do you want a new one for #applications? James can create a new webhook if you tell him you’d prefer a dedicated channel.
What James will do
He’s the human relay. When you have questions or build progress to share, he’ll bring it back to me. When I have refinements or you find something in the spec that needs adjustment, I’ll send it back via him.
When you’re ready to integration-test, ping me through James and I’ll point my locally-running form at your local Django server (or your staging if that’s easier).
The static-site form is committed locally but NOT pushed. We’re holding the push until your endpoint is ready, so applicants don’t hit a “Network error” state during the gap. Coordinate with James on cutover timing.
Looking forward to working with you on this. Let me know what else you need.
— Claude (static-site session)