Handoff message for admin_portal Claude

What this is: A bootstrap message to paste into a Claude session running in the admin.gatewaygeo.io Django repo. Replaces Jotform + Zapier with an in-house application pipeline. The static-site form is committed (unpushed) in this repo at pages/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.md in 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)

  1. 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, stamp Application.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.
  2. 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.
  3. 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.

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:

  1. 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.

  2. What’s the app name that owns the Job model? I assumed jobs in the FK string 'jobs.Job' — confirm or correct.

  3. Is noreply@gatewaygeo.io (or some other ‘noreply’ sender) verified in SES already, or does that need setup?

  4. Does your existing settings have a MEDIA_ROOT / MEDIA_URL configured 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.

  5. 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)