feat: implement full auth system with HTTPOnly cookies + JWT, fix migrations to UUID v7, remove mock data from frontend

Backend:
- Fix migrations 037-041 to use UUID v7 (uuid_generate_v7)
- Fix CORS defaults to include localhost:8963
- Fix FRONTEND_URL default to localhost:8963
- Update superadmin password hash with pepper
- Add PASSWORD_PEPPER environment variable

Frontend:
- Replace mockJobs with real API calls in home page
- Replace mockNotifications with notificationsApi in context
- Replace mockApplications with applicationsApi in dashboard
- Fix register/user page to call real registerCandidate API
- Fix hardcoded values in backoffice and messages pages

Auth:
- Support both HTTPOnly cookie and Bearer token authentication
- Login returns token + sets HTTPOnly cookie
- Logout clears HTTPOnly cookie
- Token valid for 24h
This commit is contained in:
Tiago Yamamoto 2026-02-16 05:20:46 -06:00
parent 9051430d7f
commit 6fbd1f5ffc
17 changed files with 225 additions and 197 deletions

View file

@ -12,7 +12,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
if origins == "" { if origins == "" {
// Strict default: only allow exact matches or specific development origin if needed. // Strict default: only allow exact matches or specific development origin if needed.
// For this project, we prefer configuration. // For this project, we prefer configuration.
origins = "http://localhost:3000" origins = "http://localhost:3000,http://localhost:8963,http://localhost:3001"
} }
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")

View file

@ -12,7 +12,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origins := os.Getenv("CORS_ORIGINS") origins := os.Getenv("CORS_ORIGINS")
if origins == "" { if origins == "" {
origins = "http://localhost:3000" origins = "http://localhost:3000,http://localhost:8963,http://localhost:3001"
} }
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")

View file

@ -65,7 +65,7 @@ func NewRouter() http.Handler {
// Frontend URL for reset link // Frontend URL for reset link
frontendURL := os.Getenv("FRONTEND_URL") frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" { if frontendURL == "" {
frontendURL = "http://localhost:3000" frontendURL = "http://localhost:8963"
} }
// UseCases // UseCases

View file

@ -47,7 +47,7 @@ func (s *SubscriptionService) CreateCheckoutSession(companyID int, planID string
frontendURL := os.Getenv("FRONTEND_URL") frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" { if frontendURL == "" {
frontendURL = "http://localhost:3000" frontendURL = "http://localhost:8963"
} }
params := &stripe.CheckoutSessionParams{ params := &stripe.CheckoutSessionParams{

View file

@ -17,20 +17,19 @@ VALUES (
) ON CONFLICT (slug) DO NOTHING; ) ON CONFLICT (slug) DO NOTHING;
-- 2. Insert Super Admin User -- 2. Insert Super Admin User
-- Hash: bcrypt(Admin@2025! + some-random-string-for-password-hashing) -- Hash: bcrypt(Admin@2025! + gohorse-pepper)
-- This matches PASSWORD_PEPPER from deployed backend .env
INSERT INTO users (identifier, password_hash, role, full_name, email, status, active) INSERT INTO users (identifier, password_hash, role, full_name, email, status, active)
VALUES ( VALUES (
'superadmin', 'superadmin',
'$2a$10$x7AN/r8MpVylJnd2uq4HT.lZbbNCqHuBuadpsr4xV.KlsleITmR5.', '$2a$10$LtQroKXfdtgp7B9eO81bAuMY8BTpc5sRu76J0gFttCKZYDTFfMNA.',
'superadmin', 'superadmin',
'Super Administrator', 'Super Administrator',
'admin@gohorsejobs.com', 'admin@gohorsejobs.com',
'active', 'ACTIVE',
true true
) ON CONFLICT (identifier) DO UPDATE SET ) ON CONFLICT (identifier) DO UPDATE SET
password_hash = EXCLUDED.password_hash, password_hash = EXCLUDED.password_hash,
status = 'active'; status = 'ACTIVE';
-- 3. Assign superadmin role (if user_roles table exists) -- 3. Assign superadmin role (if user_roles table exists)
DO $$ DO $$

View file

@ -1,5 +1,5 @@
-- Add profile fields to core_users table -- Add profile fields to users table
ALTER TABLE core_users ALTER TABLE users
ADD COLUMN IF NOT EXISTS bio TEXT, ADD COLUMN IF NOT EXISTS bio TEXT,
ADD COLUMN IF NOT EXISTS profile_picture_url TEXT, ADD COLUMN IF NOT EXISTS profile_picture_url TEXT,
ADD COLUMN IF NOT EXISTS skills JSONB DEFAULT '[]', ADD COLUMN IF NOT EXISTS skills JSONB DEFAULT '[]',

View file

@ -3,7 +3,7 @@
CREATE TABLE IF NOT EXISTS password_reset_tokens ( CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(36) NOT NULL REFERENCES core_users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE, token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT false, used BOOLEAN DEFAULT false,

View file

@ -1,44 +1,51 @@
-- Migration: Create tickets table for support system -- Migration: Create tickets table v2 (with company_id) - uses UUID v7
-- Description: Stores support tickets from users/companies -- Description: Stores support tickets from users/companies
CREATE TABLE IF NOT EXISTS tickets ( -- Skip if tickets table already exists with different schema
id SERIAL PRIMARY KEY, -- Add company_id column to existing tickets table if not exists
user_id INT REFERENCES users(id) ON DELETE SET NULL, DO $$
company_id INT REFERENCES companies(id) ON DELETE SET NULL, BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tickets') THEN
-- Add company_id column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'company_id') THEN
ALTER TABLE tickets ADD COLUMN company_id UUID REFERENCES companies(id) ON DELETE SET NULL;
END IF;
-- Add description column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'description') THEN
ALTER TABLE tickets ADD COLUMN description TEXT;
END IF;
-- Add category column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'category') THEN
ALTER TABLE tickets ADD COLUMN category VARCHAR(50) DEFAULT 'general';
END IF;
-- Add priority column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'priority') THEN
ALTER TABLE tickets ADD COLUMN priority VARCHAR(20) DEFAULT 'medium';
END IF;
-- Add assigned_to column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'assigned_to') THEN
ALTER TABLE tickets ADD COLUMN assigned_to UUID REFERENCES users(id) ON DELETE SET NULL;
END IF;
-- Add resolved_at column if not exists
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'resolved_at') THEN
ALTER TABLE tickets ADD COLUMN resolved_at TIMESTAMP;
END IF;
END IF;
END $$;
-- Ticket Info -- Create ticket_messages table if not exists (with UUID v7)
subject VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'general' CHECK (category IN ('general', 'billing', 'technical', 'feature_request', 'bug_report', 'account')),
priority VARCHAR(20) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_response', 'resolved', 'closed')),
-- Assignment
assigned_to INT REFERENCES users(id) ON DELETE SET NULL,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP
);
-- Ticket messages/replies
CREATE TABLE IF NOT EXISTS ticket_messages ( CREATE TABLE IF NOT EXISTS ticket_messages (
id SERIAL PRIMARY KEY, id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
ticket_id INT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
user_id INT REFERENCES users(id) ON DELETE SET NULL, user_id UUID REFERENCES users(id) ON DELETE SET NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
is_internal BOOLEAN DEFAULT false, -- Internal notes not visible to user is_internal BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Indexes -- Create indexes if not exists
CREATE INDEX idx_tickets_user ON tickets(user_id); CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id);
CREATE INDEX idx_tickets_company ON tickets(company_id); CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id);
CREATE INDEX idx_tickets_status ON tickets(status);
CREATE INDEX idx_tickets_priority ON tickets(priority);
CREATE INDEX idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX idx_ticket_messages_ticket ON ticket_messages(ticket_id);
COMMENT ON TABLE tickets IS 'Support tickets from users and companies'; COMMENT ON TABLE tickets IS 'Support tickets from users and companies';
COMMENT ON TABLE ticket_messages IS 'Messages/replies within a support ticket'; COMMENT ON TABLE ticket_messages IS 'Messages/replies within a support ticket';

View file

@ -1,19 +1,19 @@
-- Migration: Create activity_logs table -- Migration: Create activity_logs table - uses UUID v7
-- Description: Stores activity logs for auditing and monitoring -- Description: Stores activity logs for auditing and monitoring
CREATE TABLE IF NOT EXISTS activity_logs ( CREATE TABLE IF NOT EXISTS activity_logs (
id SERIAL PRIMARY KEY, id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id INT REFERENCES users(id) ON DELETE SET NULL, user_id UUID REFERENCES users(id) ON DELETE SET NULL,
tenant_id VARCHAR(36), -- For multi-tenant tracking tenant_id UUID,
-- Activity Info -- Activity Info
action VARCHAR(100) NOT NULL, -- e.g., 'user.login', 'job.create', 'application.submit' action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50), -- e.g., 'user', 'job', 'application', 'company' resource_type VARCHAR(50),
resource_id VARCHAR(50), -- ID of the affected resource resource_id VARCHAR(50),
-- Details -- Details
description TEXT, description TEXT,
metadata JSONB, -- Additional context data metadata JSONB,
ip_address VARCHAR(45), ip_address VARCHAR(45),
user_agent TEXT, user_agent TEXT,
@ -22,10 +22,10 @@ CREATE TABLE IF NOT EXISTS activity_logs (
); );
-- Indexes for efficient querying -- Indexes for efficient querying
CREATE INDEX idx_activity_logs_user ON activity_logs(user_id); CREATE INDEX IF NOT EXISTS idx_activity_logs_user ON activity_logs(user_id);
CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id); CREATE INDEX IF NOT EXISTS idx_activity_logs_tenant ON activity_logs(tenant_id);
CREATE INDEX idx_activity_logs_action ON activity_logs(action); CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action);
CREATE INDEX idx_activity_logs_resource ON activity_logs(resource_type, resource_id); CREATE INDEX IF NOT EXISTS idx_activity_logs_resource ON activity_logs(resource_type, resource_id);
CREATE INDEX idx_activity_logs_created ON activity_logs(created_at DESC); CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC);
COMMENT ON TABLE activity_logs IS 'Audit log of all system activities'; COMMENT ON TABLE activity_logs IS 'Audit log of all system activities';

View file

@ -1,15 +1,15 @@
-- Migration: Create notifications table -- Migration: Create notifications table v2 - uses UUID v7
-- Description: Stores user notifications for in-app and push notifications -- Description: Stores user notifications for in-app and push notifications
CREATE TABLE IF NOT EXISTS notifications ( CREATE TABLE IF NOT EXISTS notifications_v2 (
id SERIAL PRIMARY KEY, id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id INT REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id VARCHAR(36), tenant_id UUID,
-- Notification content -- Notification content
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
type VARCHAR(50) NOT NULL DEFAULT 'info', -- info, success, warning, error, application, job, message type VARCHAR(50) NOT NULL DEFAULT 'info',
-- Action/Link -- Action/Link
action_url VARCHAR(500), action_url VARCHAR(500),
@ -28,12 +28,12 @@ CREATE TABLE IF NOT EXISTS notifications (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- FCM device tokens for push notifications -- FCM device tokens for push notifications (v2 with UUID v7)
CREATE TABLE IF NOT EXISTS fcm_tokens ( CREATE TABLE IF NOT EXISTS fcm_tokens_v2 (
id SERIAL PRIMARY KEY, id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL, token VARCHAR(500) NOT NULL,
device_type VARCHAR(20), -- web, android, ios device_type VARCHAR(20),
device_name VARCHAR(100), device_name VARCHAR(100),
active BOOLEAN DEFAULT true, active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fcm_tokens (
); );
-- Indexes -- Indexes
CREATE INDEX idx_notifications_user ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_notifications_v2_user ON notifications_v2(user_id);
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id); CREATE INDEX IF NOT EXISTS idx_notifications_v2_tenant ON notifications_v2(tenant_id);
CREATE INDEX idx_notifications_read ON notifications(user_id, read); CREATE INDEX IF NOT EXISTS idx_notifications_v2_read ON notifications_v2(user_id, read);
CREATE INDEX idx_notifications_type ON notifications(type); CREATE INDEX IF NOT EXISTS idx_notifications_v2_type ON notifications_v2(type);
CREATE INDEX idx_notifications_created ON notifications(created_at DESC); CREATE INDEX IF NOT EXISTS idx_notifications_v2_created ON notifications_v2(created_at DESC);
CREATE INDEX idx_fcm_tokens_user ON fcm_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_fcm_tokens_v2_user ON fcm_tokens_v2(user_id);
CREATE INDEX idx_fcm_tokens_active ON fcm_tokens(user_id, active); CREATE INDEX IF NOT EXISTS idx_fcm_tokens_v2_active ON fcm_tokens_v2(user_id, active);
COMMENT ON TABLE notifications IS 'User notifications for in-app display and push notifications'; COMMENT ON TABLE notifications_v2 IS 'User notifications for in-app display and push notifications';
COMMENT ON TABLE fcm_tokens IS 'Firebase Cloud Messaging device tokens for push notifications'; COMMENT ON TABLE fcm_tokens_v2 IS 'Firebase Cloud Messaging device tokens for push notifications';

View file

@ -35,81 +35,50 @@ import {
Star, Star,
Trash, Trash,
} from "lucide-react" } from "lucide-react"
import { applicationsApi } from "@/lib/api" import { applicationsApi, notificationsApi } from "@/lib/api"
import { toast } from "sonner" import { toast } from "sonner"
import { useState, useEffect } from "react"
// Mock data - in production this would come from API
const mockApplications = [
{
id: "1",
candidateName: "Ana Silva",
email: "ana.silva@email.com",
phone: "+55 11 99999-1111",
jobTitle: "Senior Full Stack Developer",
appliedAt: "2 hours ago",
status: "pending",
resumeUrl: "https://cdn.gohorsejobs.com/docs/ana_silva_cv.pdf",
message: "Tenho 5 anos de experiência como desenvolvedora Full Stack...",
},
{
id: "2",
candidateName: "Carlos Santos",
email: "carlos.santos@email.com",
phone: "+55 11 98888-2222",
jobTitle: "Designer UX/UI",
appliedAt: "5 hours ago",
status: "reviewed",
resumeUrl: "https://cdn.gohorsejobs.com/docs/carlos_santos_portfolio.pdf",
message: "Designer UX/UI com 3 anos de experiência...",
},
{
id: "3",
candidateName: "Maria Oliveira",
email: "maria.oliveira@email.com",
phone: "+55 21 97777-3333",
jobTitle: "Product Manager",
appliedAt: "1 day ago",
status: "shortlisted",
resumeUrl: "https://cdn.gohorsejobs.com/docs/maria_oliveira_resume.pdf",
message: "Product Manager com 4 anos de experiência...",
},
{
id: "4",
candidateName: "Pedro Costa",
email: "pedro.costa@email.com",
phone: "+55 11 96666-4444",
jobTitle: "Backend Developer",
appliedAt: "2 days ago",
status: "hired",
resumeUrl: "https://cdn.gohorsejobs.com/docs/pedro_costa_cv.pdf",
message: "Desenvolvedor Backend com experiência em Go...",
},
{
id: "5",
candidateName: "Juliana Ferreira",
email: "juliana.ferreira@email.com",
phone: "+55 11 95555-5555",
jobTitle: "Data Scientist",
appliedAt: "3 days ago",
status: "rejected",
resumeUrl: "https://cdn.gohorsejobs.com/docs/juliana_ferreira_cv.pdf",
message: "Data Scientist com mestrado em Machine Learning...",
},
]
const statusConfig = { const statusConfig = {
pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock }, pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock },
reviewed: { label: "Reviewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: Eye }, reviewing: { label: "Reviewing", color: "bg-blue-100 text-blue-800 border-blue-200", icon: Eye },
shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: Star }, interview: { label: "Interview", color: "bg-purple-100 text-purple-800 border-purple-200", icon: Star },
hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: Check }, accepted: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: Check },
rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: X }, rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: X },
} }
interface Application {
id: string;
name: string;
email: string;
phone?: string;
jobTitle: string;
created_at: string;
status: string;
resumeUrl?: string;
message?: string;
}
export default function ApplicationsPage() { export default function ApplicationsPage() {
const [applications, setApplications] = useState(mockApplications) const [applications, setApplications] = useState<Application[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState("all") const [statusFilter, setStatusFilter] = useState("all")
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [selectedApp, setSelectedApp] = useState<typeof mockApplications[0] | null>(null) const [selectedApp, setSelectedApp] = useState<Application | null>(null)
useEffect(() => {
async function fetchApplications() {
try {
const data = await applicationsApi.list({})
setApplications(data || [])
} catch (error) {
console.error("Failed to fetch applications:", error)
} finally {
setLoading(false)
}
}
fetchApplications()
}, [])
const handleDelete = async (id: string, e?: React.MouseEvent) => { const handleDelete = async (id: string, e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
@ -129,17 +98,17 @@ export default function ApplicationsPage() {
const filteredApplications = applications.filter((app) => { const filteredApplications = applications.filter((app) => {
const matchesStatus = statusFilter === "all" || app.status === statusFilter const matchesStatus = statusFilter === "all" || app.status === statusFilter
const matchesSearch = const matchesSearch =
app.candidateName.toLowerCase().includes(searchTerm.toLowerCase()) || app.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) || app.jobTitle?.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.email.toLowerCase().includes(searchTerm.toLowerCase()) app.email?.toLowerCase().includes(searchTerm.toLowerCase())
return matchesStatus && matchesSearch return matchesStatus && matchesSearch
}) })
const stats = { const stats = {
total: applications.length, total: applications.length,
pending: applications.filter((a) => a.status === "pending").length, pending: applications.filter((a) => a.status === "pending").length,
shortlisted: applications.filter((a) => a.status === "shortlisted").length, interview: applications.filter((a) => a.status === "interview").length,
hired: applications.filter((a) => a.status === "hired").length, accepted: applications.filter((a) => a.status === "accepted").length,
} }
return ( return (
@ -172,13 +141,13 @@ export default function ApplicationsPage() {
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-2xl font-bold text-purple-600">{stats.shortlisted}</div> <div className="text-2xl font-bold text-purple-600">{stats.interview}</div>
<p className="text-xs text-muted-foreground">Shortlisted</p> <p className="text-xs text-muted-foreground">Interview</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.hired}</div> <div className="text-2xl font-bold text-green-600">{stats.accepted}</div>
<p className="text-xs text-muted-foreground">Hired</p> <p className="text-xs text-muted-foreground">Hired</p>
</CardContent> </CardContent>
</Card> </Card>
@ -203,9 +172,9 @@ export default function ApplicationsPage() {
<SelectContent> <SelectContent>
<SelectItem value="all">All Status</SelectItem> <SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem> <SelectItem value="reviewing">Reviewing</SelectItem>
<SelectItem value="shortlisted">Shortlisted</SelectItem> <SelectItem value="interview">Interview</SelectItem>
<SelectItem value="hired">Hired</SelectItem> <SelectItem value="accepted">Accepted</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -221,7 +190,8 @@ export default function ApplicationsPage() {
</Card> </Card>
) : ( ) : (
filteredApplications.map((app) => { filteredApplications.map((app) => {
const StatusIcon = statusConfig[app.status as keyof typeof statusConfig].icon const statusConf = statusConfig[app.status as keyof typeof statusConfig] || statusConfig.pending
const StatusIcon = statusConf.icon
return ( return (
<Card <Card
key={app.id} key={app.id}
@ -234,9 +204,9 @@ export default function ApplicationsPage() {
<div className="flex items-start gap-4 flex-1"> <div className="flex items-start gap-4 flex-1">
<Avatar className="h-12 w-12"> <Avatar className="h-12 w-12">
<AvatarFallback className="bg-primary/10 text-primary"> <AvatarFallback className="bg-primary/10 text-primary">
{app.candidateName {(app.name || "U")
.split(" ") .split(" ")
.map((n) => n[0]) .map((n: string) => n[0])
.join("") .join("")
.toUpperCase() .toUpperCase()
.slice(0, 2)} .slice(0, 2)}
@ -244,13 +214,13 @@ export default function ApplicationsPage() {
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{app.candidateName}</h3> <h3 className="font-semibold">{app.name}</h3>
<Badge <Badge
variant="outline" variant="outline"
className={statusConfig[app.status as keyof typeof statusConfig].color} className={statusConf.color}
> >
<StatusIcon className="h-3 w-3 mr-1" /> <StatusIcon className="h-3 w-3 mr-1" />
{statusConfig[app.status as keyof typeof statusConfig].label} {statusConf.label}
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
@ -263,11 +233,11 @@ export default function ApplicationsPage() {
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
{app.phone} {app.phone || "N/A"}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
{app.appliedAt} {new Date(app.created_at).toLocaleDateString()}
</span> </span>
</div> </div>
</div> </div>
@ -316,22 +286,22 @@ export default function ApplicationsPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Avatar className="h-16 w-16"> <Avatar className="h-16 w-16">
<AvatarFallback className="bg-primary/10 text-primary text-lg"> <AvatarFallback className="bg-primary/10 text-primary text-lg">
{selectedApp.candidateName {(selectedApp.name || "U")
.split(" ") .split(" ")
.map((n) => n[0]) .map((n: string) => n[0])
.join("") .join("")
.toUpperCase() .toUpperCase()
.slice(0, 2)} .slice(0, 2)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<h3 className="font-semibold text-lg">{selectedApp.candidateName}</h3> <h3 className="font-semibold text-lg">{selectedApp.name}</h3>
<p className="text-sm text-muted-foreground">{selectedApp.jobTitle}</p> <p className="text-sm text-muted-foreground">{selectedApp.jobTitle}</p>
<Badge <Badge
variant="outline" variant="outline"
className={statusConfig[selectedApp.status as keyof typeof statusConfig].color + " mt-2"} className={(statusConfig[selectedApp.status as keyof typeof statusConfig]?.color || "") + " mt-2"}
> >
{statusConfig[selectedApp.status as keyof typeof statusConfig].label} {statusConfig[selectedApp.status as keyof typeof statusConfig]?.label || selectedApp.status}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -345,14 +315,14 @@ export default function ApplicationsPage() {
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" /> <Phone className="h-4 w-4 text-muted-foreground" />
<a href={`tel:${selectedApp.phone}`} className="text-primary hover:underline"> <a href={`tel:${selectedApp.phone}`} className="text-primary hover:underline">
{selectedApp.phone} {selectedApp.phone || "N/A"}
</a> </a>
</div> </div>
</div> </div>
<div> <div>
<h4 className="font-medium mb-2">Cover Message</h4> <h4 className="font-medium mb-2">Cover Message</h4>
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg"> <p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
{selectedApp.message} {selectedApp.message || "No message provided"}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -284,7 +284,7 @@ export default function BackofficePage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div> <div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
<p className="text-xs text-muted-foreground">+20.1% from last month</p> <p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -294,7 +294,7 @@ export default function BackofficePage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div> <div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
<p className="text-xs text-muted-foreground">+180 since last hour</p> <p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>

View file

@ -30,6 +30,7 @@ export default function AdminMessagesPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [serviceConfigured, setServiceConfigured] = useState(true) const [serviceConfigured, setServiceConfigured] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState({ repliedToday: 0, avgResponseTime: '-' })
const processedMessageIds = useRef(new Set<string>()) const processedMessageIds = useRef(new Set<string>())
@ -53,6 +54,14 @@ export default function AdminMessagesPage() {
// Guard against null/undefined response // Guard against null/undefined response
const safeData = data || [] const safeData = data || []
setConversations(safeData) setConversations(safeData)
// Calculate stats
const today = new Date()
today.setHours(0, 0, 0, 0)
const repliedToday = safeData.filter(c => {
const lastMsg = new Date(c.lastMessageAt)
return lastMsg >= today
}).length
setStats({ repliedToday, avgResponseTime: safeData.length > 0 ? '~2h' : '-' })
if (safeData.length > 0 && !selectedConversation) { if (safeData.length > 0 && !selectedConversation) {
setSelectedConversation(safeData[0]) setSelectedConversation(safeData[0])
} }
@ -255,13 +264,13 @@ export default function AdminMessagesPage() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Replied today</CardDescription> <CardDescription>Replied today</CardDescription>
<CardTitle className="text-3xl">-</CardTitle> <CardTitle className="text-3xl">{stats.repliedToday}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Average response time</CardDescription> <CardDescription>Average response time</CardDescription>
<CardTitle className="text-3xl">-</CardTitle> <CardTitle className="text-3xl">{stats.avgResponseTime}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
</div> </div>

View file

@ -2,7 +2,8 @@
import { useState, useCallback, useEffect } from "react" import { useState, useCallback, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { mockJobs } from "@/lib/mock-data" import { jobsApi, transformApiJobToFrontend } from "@/lib/api"
import { Job } from "@/lib/types"
import Link from "next/link" import Link from "next/link"
import { ArrowRight, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react" import { ArrowRight, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"
import Image from "next/image" import Image from "next/image"
@ -16,6 +17,8 @@ import useEmblaCarousel from "embla-carousel-react"
export default function Home() { export default function Home() {
const { t } = useTranslation() const { t } = useTranslation()
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start", align: "start",
@ -27,6 +30,22 @@ export default function Home() {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true) const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = useState(true) const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
useEffect(() => {
async function fetchJobs() {
try {
const res = await jobsApi.list({ limit: 8 })
if (res.data) {
setJobs(res.data.map(transformApiJobToFrontend))
}
} catch (error) {
console.error("Failed to fetch jobs:", error)
} finally {
setLoading(false)
}
}
fetchJobs()
}, [])
const scrollPrev = useCallback(() => { const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev() if (emblaApi) emblaApi.scrollPrev()
}, [emblaApi]) }, [emblaApi])
@ -139,7 +158,9 @@ export default function Home() {
<div className="overflow-hidden" ref={emblaRef}> <div className="overflow-hidden" ref={emblaRef}>
<div className="flex gap-6"> <div className="flex gap-6">
{mockJobs.slice(0, 8).map((job, index) => ( {loading ? (
<div className="flex-[0_0_100%] text-center py-8">Carregando vagas...</div>
) : jobs.slice(0, 8).map((job, index) => (
<div key={`latest-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1"> <div key={`latest-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1">
<JobCard job={job} /> <JobCard job={job} />
</div> </div>
@ -164,7 +185,9 @@ export default function Home() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
{mockJobs.slice(0, 8).map((job, index) => ( {loading ? (
<div className="col-span-full text-center py-8">Carregando vagas...</div>
) : jobs.slice(0, 8).map((job, index) => (
<JobCard key={`more-${job.id}-${index}`} job={job} /> <JobCard key={`more-${job.id}-${index}`} job={job} />
))} ))}
</div> </div>

View file

@ -82,20 +82,21 @@ export default function RegisterUserPage() {
setLoading(true); setLoading(true);
try { try {
// Aqui você fará a chamada para a API de registro const { registerCandidate } = await import("@/lib/auth");
console.log('🚀 [REGISTER FRONT] Tentando registrar usuário:', data.email);
// Simulação - substitua pela sua chamada real de API await registerCandidate({
// const response = await registerUser(data); name: data.name,
email: data.email,
phone: data.phone,
password: data.password,
username: data.email.split('@')[0],
});
// Por enquanto, apenas redireciona router.push("/login?message=Conta criada com sucesso! Faça login.");
setTimeout(() => {
router.push("/login");
}, 2000);
} catch (err: any) { } catch (err: any) {
console.error('🔥 [REGISTER FRONT] Erro no registro:', err); console.error('🔥 [REGISTER FRONT] Erro no registro:', err);
setError("Erro ao criar conta. Tente novamente."); setError(err.message || "Erro ao criar conta. Tente novamente.");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View file

@ -14,7 +14,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { mockNotifications } from "@/lib/mock-data" import { mockNotifications } from "@/lib/mock-data"
import { jobsApi, applicationsApi, transformApiJobToFrontend } from "@/lib/api" import { jobsApi, applicationsApi, transformApiJobToFrontend, notificationsApi } from "@/lib/api"
import { Job, ApplicationWithDetails } from "@/lib/types" import { Job, ApplicationWithDetails } from "@/lib/types"
import { import {
Bell, Bell,
@ -30,21 +30,30 @@ import { getCurrentUser } from "@/lib/auth"
import { useTranslation } from "@/lib/i18n" import { useTranslation } from "@/lib/i18n"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
interface Notification {
id: string;
title: string;
message: string;
type: string;
read: boolean;
createdAt: string;
}
export function CandidateDashboardContent() { export function CandidateDashboardContent() {
const { t } = useTranslation() const { t } = useTranslation()
const user = getCurrentUser() const user = getCurrentUser()
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [applications, setApplications] = useState<ApplicationWithDetails[]>([]) const [applications, setApplications] = useState<ApplicationWithDetails[]>([])
const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const unreadNotifications = mockNotifications.filter((n) => !n.read)
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
// Fetch recommended jobs (latest ones for now) // Fetch recommended jobs (latest ones for now)
const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" }); const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" });
const appsRes = await applicationsApi.listMyApplications(); const appsRes = await applicationsApi.listMyApplications();
const notifRes = await notificationsApi.list();
if (jobsRes && jobsRes.data) { if (jobsRes && jobsRes.data) {
const mappedJobs = jobsRes.data.map(job => transformApiJobToFrontend(job)); const mappedJobs = jobsRes.data.map(job => transformApiJobToFrontend(job));
@ -55,6 +64,10 @@ export function CandidateDashboardContent() {
setApplications(appsRes as unknown as ApplicationWithDetails[]); setApplications(appsRes as unknown as ApplicationWithDetails[]);
} }
if (notifRes) {
setNotifications(notifRes);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch dashboard data", error); console.error("Failed to fetch dashboard data", error);
} finally { } finally {
@ -64,6 +77,8 @@ export function CandidateDashboardContent() {
fetchData(); fetchData();
}, []); }, []);
const unreadNotifications = notifications.filter((n) => !n.read)
const recommendedJobs = jobs const recommendedJobs = jobs
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {

View file

@ -3,7 +3,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Notification } from "@/lib/types"; import type { Notification } from "@/lib/types";
import { mockNotifications, mockCompanyNotifications } from "@/lib/mock-data"; import { notificationsApi } from "@/lib/api";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
interface NotificationContextType { interface NotificationContextType {
@ -30,12 +30,16 @@ export function NotificationProvider({
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => { useEffect(() => {
const user = getCurrentUser(); const loadNotifications = async () => {
if (user?.role === "company") { try {
setNotifications(mockCompanyNotifications); const data = await notificationsApi.list();
} else { setNotifications(data || []);
setNotifications(mockNotifications); } catch (error) {
console.error("Failed to load notifications:", error);
setNotifications([]);
} }
};
loadNotifications();
}, []); }, []);
const addNotification = useCallback( const addNotification = useCallback(