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
32 changes: 25 additions & 7 deletions .claude/commands/ci-audit.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
6s
Run npm run build
---
description: Run all CI audit checks locally to catch issues before pushing. Go through each check sequentially and report results. Fix any issues you find automatically.
model: sonnet
---

> web@0.0.0 build
> tsc -b && vite build
Run the CI audit checks locally to catch issues before pushing. Only run the checks for the parts of the codebase that actually changed, go through each relevant check sequentially, report results, and fix any issues you find automatically.

Error: src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx(57,7): error TS2722: Cannot invoke an object which is possibly 'undefined'.
Error: Process completed with exit code 2.Run all CI audit checks locally to catch issues before pushing. Go through each check sequentially and report results. Fix any issues you find automatically.
## Determine what changed

First, figure out which sections have changes so you can skip irrelevant checks:

1. Run `git status --porcelain` and `git diff --name-only HEAD` to list modified, staged, and untracked files (include both committed-but-unpushed and working-tree changes).
2. Classify the changed paths:
- **Backend changes** — any changed file ending in `.go`, plus `go.mod`/`go.sum`.
- **Frontend changes** — any changed file under `client/web/`.
3. Decide which sections to run:
- Backend changes present → run the **Backend checks**.
- Frontend changes present → run the **Frontend checks**.
- Both present → run both sections.
- Neither (e.g. only docs/config) → report that there's nothing to audit and stop.

State up front which sections you're running and why (based on the detected changes).

## Backend checks (from repo root)

Run only if backend changes were detected.

1. **gofmt** — Run `gofmt -l .` and verify no files are unformatted. If any are, run `gofmt -w .` to fix them.
2. **go mod verify** — Run `go mod verify`.
3. **go build** — Run `go build -v ./...`.
Expand All @@ -17,11 +33,13 @@ Error: Process completed with exit code 2.Run all CI audit checks locally to cat

## Frontend checks (from `client/web/`)

Run only if frontend changes were detected.

6. **Format check** — Run `npm run format:check`. If it fails, run `npm run format` to fix, then re-check.
7. **Lint** — Run `npm run lint`. If it fails, report the errors and attempt to fix them.
8. **Build** — Run `npm run build` (includes TypeScript type checking).
9. **Dependency audit** — Run `npm audit --audit-level=high --omit=dev`.

## Output

After all checks complete, print a summary table showing each check's pass/fail status. If any check failed and could not be auto-fixed, explain what needs to be done.
After all relevant checks complete, print a summary table showing each check's pass/fail status. Mark skipped sections as **skipped (no changes)**. If any check failed and could not be auto-fixed, explain what needs to be done.
5 changes: 5 additions & 0 deletions .claude/commands/create-task.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
description: Create a new task in both ClickUp (Tech list) and GitHub Issues, then link them together.
model: opus
---

# Create Task

Create a new task in both ClickUp (Tech list) and GitHub Issues, then link them together.
Expand Down
12 changes: 1 addition & 11 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type config struct {
appURL string
frontendURL string
hackathonTimeZone string
mail mailConfig
mail mailer.Config
gcs gcsConfig
auth authConfig
rateLimiter ratelimiter.Config
Expand Down Expand Up @@ -71,16 +71,6 @@ type basicConfig struct {
user string
pass string
}

type mailConfig struct {
sendGrid sendGridConfig
fromEmail string
}

type sendGridConfig struct {
apiKey string
}

type gcsConfig struct {
bucketName string
}
Expand Down
22 changes: 16 additions & 6 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,18 @@ func main() {
maxIdleTime: env.GetString("DB_MAX_IDLE_TIME", "15m"),
},
env: env.GetString("ENV", "development"),
mail: mailConfig{
sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""),
mail: mailer.Config{
SendGrid: mailer.SendGridConfig{
APIKey: env.GetString("SENDGRID_API_KEY", ""),
},
fromEmail: env.GetString("MAIL_FROM", "noreply@hackportal.com"),
SMTP: mailer.SMTPConfig{
Host: env.GetString("EMAIL_HOST", ""),
Port: env.GetInt("EMAIL_PORT", 587),
Username: env.GetString("EMAIL_USERNAME", ""),
Password: env.GetString("EMAIL_PASSWORD", ""),
},
FromEmail: env.GetString("EMAIL_FROM", "noreply@example.com"),
FromName: env.GetString("EMAIL_FROM_NAME", mailer.FromName),
},
gcs: gcsConfig{
bucketName: env.GetString("GCS_BUCKET_NAME", ""),
Expand Down Expand Up @@ -137,8 +144,11 @@ func main() {
}
logger.Info("supertokens initialized")

// Init mailer
mailClient := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
// Init mailer — picks provider from .env SMTP or SendGrid, at least one is required
mailClient, err := mailer.New(cfg.mail)
if err != nil {
logger.Fatal("failed to initialize mailer", zap.Error(err))
}

// Init GCS (optional in local/dev)
var gcsClient gcs.Client
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ require (
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/h2non/gock.v1 v1.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
46 changes: 45 additions & 1 deletion internal/mailer/mailer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package mailer

import "embed"
import (
"embed"
"fmt"
)

const (
FromName = "HackUTD"
Expand All @@ -12,3 +15,44 @@ var FS embed.FS
type Client interface {
SendQREmail(toEmail, toName, userID string) error
}

type Config struct {
FromEmail string
FromName string
SendGrid SendGridConfig
SMTP SMTPConfig
}

type SendGridConfig struct {
APIKey string
}

type SMTPConfig struct {
Host string
Port int
Username string
Password string
}

// New selects a mailer provider based on cfg: SMTP when EMAIL_HOST is set,
// otherwise SendGrid when SENDGRID_API_KEY is set. At least one is required.
func New(cfg Config) (Client, error) {
switch {
case cfg.SMTP.Host != "":
if cfg.SMTP.Username == "" || cfg.SMTP.Password == "" {
return nil, fmt.Errorf("EMAIL_HOST is set but EMAIL_USERNAME and EMAIL_PASSWORD are required for SMTP")
}
return NewSMTP(
cfg.SMTP.Host,
cfg.SMTP.Port,
cfg.SMTP.Username,
cfg.SMTP.Password,
cfg.FromEmail,
cfg.FromName,
), nil
case cfg.SendGrid.APIKey != "":
return NewSendGrid(cfg.SendGrid.APIKey, cfg.FromEmail, cfg.FromName), nil
default:
return nil, fmt.Errorf("no mailer configured: set SMTP (EMAIL_HOST, EMAIL_USERNAME, EMAIL_PASSWORD) or SENDGRID_API_KEY")
}
}
12 changes: 8 additions & 4 deletions internal/mailer/sendgrid.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ import (

type SendGridMailer struct {
fromEmail string
apiKey string
fromName string
client *sendgrid.Client
}

func NewSendGrid(apiKey, fromEmail string) *SendGridMailer {
func NewSendGrid(apiKey, fromEmail, fromName string) *SendGridMailer {
client := sendgrid.NewSendClient(apiKey)

if fromName == "" {
fromName = FromName
}

return &SendGridMailer{
fromEmail: fromEmail,
apiKey: apiKey,
fromName: fromName,
client: client,
}
}
Expand Down Expand Up @@ -51,7 +55,7 @@ func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error {
return fmt.Errorf("executing email template: %w", err)
}

from := mail.NewEmail(FromName, m.fromEmail)
from := mail.NewEmail(m.fromName, m.fromEmail)
to := mail.NewEmail(toName, toEmail)

message := mail.NewV3Mail()
Expand Down
84 changes: 84 additions & 0 deletions internal/mailer/smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package mailer

import (
"bytes"
"fmt"
"html/template"
"io"

qrcode "github.com/skip2/go-qrcode"
gomail "gopkg.in/gomail.v2"
)

type SMTPMailer struct {
host string
port int
username string
password string
fromEmail string
fromName string
}

func NewSMTP(host string, port int, username, password, fromEmail, fromName string) *SMTPMailer {
if port == 0 {
port = 587
}
if fromEmail == "" {
fromEmail = username
}
if fromName == "" {
fromName = FromName
}

return &SMTPMailer{
host: host,
port: port,
username: username,
password: password,
fromEmail: fromEmail,
fromName: fromName,
}
}

func (m *SMTPMailer) SendQREmail(toEmail, toName, userID string) error {
qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256)
if err != nil {
return fmt.Errorf("generating QR code: %w", err)
}

tmplData, err := FS.ReadFile("template/qr_email.html")
if err != nil {
return fmt.Errorf("reading email template: %w", err)
}

tmpl, err := template.New("qr_email").Parse(string(tmplData))
if err != nil {
return fmt.Errorf("parsing email template: %w", err)
}

var htmlBody bytes.Buffer
if err := tmpl.Execute(&htmlBody, map[string]string{"Name": toName}); err != nil {
return fmt.Errorf("executing email template: %w", err)
}

msg := gomail.NewMessage()
msg.SetAddressHeader("From", m.fromEmail, m.fromName)
msg.SetAddressHeader("To", toEmail, toName)
msg.SetHeader("Subject", "Your HackUTD QR Code")
msg.SetBody("text/html", htmlBody.String())
msg.Attach(
"hackutd-qrcode.png",
gomail.SetCopyFunc(func(w io.Writer) error {
_, err := w.Write(qrPNG)
return err
}),
gomail.SetHeader(map[string][]string{"Content-Type": {"image/png"}}),
)

dialer := gomail.NewDialer(m.host, m.port, m.username, m.password)
if err := dialer.DialAndSend(msg); err != nil {
return fmt.Errorf("sending email: %w", err)
}

return nil
}
Loading