- Backend: Email producer (LavinMQ), EmailService interface - Backend: CRUD API for email_templates and email_settings - Backend: avatar_url field in users table + UpdateMyProfile support - Backend: StorageService for pre-signed URLs - NestJS: Email consumer with Nodemailer and Handlebars - Frontend: Email Templates admin pages (list/edit) - Frontend: Updated profileApi.uploadAvatar with pre-signed URL flow - Frontend: New /post-job public page (company registration + job creation wizard) - Migrations: 027_create_email_system.sql, 028_add_avatar_url_to_users.sql
156 lines
4.4 KiB
Go
156 lines
4.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
type ChatService struct {
|
|
DB *sql.DB
|
|
Appwrite *AppwriteService
|
|
}
|
|
|
|
func NewChatService(db *sql.DB, appwrite *AppwriteService) *ChatService {
|
|
return &ChatService{
|
|
DB: db,
|
|
Appwrite: appwrite,
|
|
}
|
|
}
|
|
|
|
type Message struct {
|
|
ID string `json:"id"`
|
|
ConversationID string `json:"conversationId"`
|
|
SenderID string `json:"senderId"`
|
|
Content string `json:"content"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
IsMine bool `json:"isMine"` // Populated by handler/frontend logic
|
|
}
|
|
|
|
type Conversation struct {
|
|
ID string `json:"id"`
|
|
CandidateID string `json:"candidateId"`
|
|
CompanyID string `json:"companyId"`
|
|
JobID *string `json:"jobId"`
|
|
LastMessage *string `json:"lastMessage"`
|
|
LastMessageAt *time.Time `json:"lastMessageAt"`
|
|
ParticipantName string `json:"participantName"`
|
|
ParticipantAvatar string `json:"participantAvatar"`
|
|
UnreadCount int `json:"unreadCount"`
|
|
}
|
|
|
|
func (s *ChatService) SendMessage(ctx context.Context, senderID, conversationID, content string) (*Message, error) {
|
|
// 1. Insert into Postgres
|
|
var msgID string
|
|
var createdAt time.Time
|
|
query := `
|
|
INSERT INTO messages (conversation_id, sender_id, content)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, created_at
|
|
`
|
|
err := s.DB.QueryRowContext(ctx, query, conversationID, senderID, content).Scan(&msgID, &createdAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to insert message: %w", err)
|
|
}
|
|
|
|
// 2. Update Conversation (async-ish if needed, but important for sorting)
|
|
updateQuery := `
|
|
UPDATE conversations
|
|
SET last_message = $1, last_message_at = $2, updated_at = $2
|
|
WHERE id = $3
|
|
`
|
|
if _, err := s.DB.ExecContext(ctx, updateQuery, content, createdAt, conversationID); err != nil {
|
|
// Log error but assume message is sent
|
|
fmt.Printf("Failed to update conversation: %v\n", err)
|
|
}
|
|
|
|
// 3. Push to Appwrite
|
|
go func() {
|
|
// Fire and forget for realtime
|
|
err := s.Appwrite.PushMessage(context.Background(), msgID, conversationID, senderID, content)
|
|
if err != nil {
|
|
fmt.Printf("Appwrite push failed: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
return &Message{
|
|
ID: msgID,
|
|
ConversationID: conversationID,
|
|
SenderID: senderID,
|
|
Content: content,
|
|
CreatedAt: createdAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ChatService) ListMessages(ctx context.Context, conversationID string) ([]Message, error) {
|
|
query := `
|
|
SELECT id, conversation_id, sender_id, content, created_at
|
|
FROM messages
|
|
WHERE conversation_id = $1
|
|
ORDER BY created_at ASC
|
|
`
|
|
rows, err := s.DB.QueryContext(ctx, query, conversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var msgs []Message
|
|
for rows.Next() {
|
|
var m Message
|
|
if err := rows.Scan(&m.ID, &m.ConversationID, &m.SenderID, &m.Content, &m.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
msgs = append(msgs, m)
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
// ListConversations lists conversations for a user (candidate or company admin)
|
|
func (s *ChatService) ListConversations(ctx context.Context, userID, tenantID string, isCandidate bool) ([]Conversation, error) {
|
|
var query string
|
|
var args []interface{}
|
|
|
|
if isCandidate {
|
|
// User is candidate -> Fetch company info
|
|
query = `
|
|
SELECT c.id, c.candidate_id, c.company_id, c.job_id, c.last_message, c.last_message_at,
|
|
comp.name as participant_name
|
|
FROM conversations c
|
|
JOIN companies comp ON c.company_id = comp.id
|
|
WHERE c.candidate_id = $1
|
|
ORDER BY c.last_message_at DESC NULLS LAST
|
|
`
|
|
args = append(args, userID)
|
|
} else if tenantID != "" {
|
|
// User is company admin -> Fetch candidate info
|
|
query = `
|
|
SELECT c.id, c.candidate_id, c.company_id, c.job_id, c.last_message, c.last_message_at,
|
|
COALESCE(u.name, u.full_name, u.identifier) as participant_name
|
|
FROM conversations c
|
|
JOIN users u ON c.candidate_id = u.id
|
|
WHERE c.company_id = $1
|
|
ORDER BY c.last_message_at DESC NULLS LAST
|
|
`
|
|
args = append(args, tenantID)
|
|
} else {
|
|
return nil, fmt.Errorf("invalid context for listing conversations")
|
|
}
|
|
|
|
rows, err := s.DB.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var convs []Conversation
|
|
for rows.Next() {
|
|
var c Conversation
|
|
if err := rows.Scan(&c.ID, &c.CandidateID, &c.CompanyID, &c.JobID, &c.LastMessage, &c.LastMessageAt, &c.ParticipantName); err != nil {
|
|
return nil, err
|
|
}
|
|
convs = append(convs, c)
|
|
}
|
|
return convs, nil
|
|
}
|