mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// Contact represents a person in the CRM
|
||||
type Contact struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
CompanyID *string `json:"companyId,omitempty"`
|
||||
CompanyName string `json:"companyName,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Company represents an organization in the CRM
|
||||
type Company struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Website string `json:"website"`
|
||||
Industry string `json:"industry"`
|
||||
Size string `json:"size"`
|
||||
Notes string `json:"notes"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ContactTaskLink links a contact to a task
|
||||
type ContactTaskLink struct {
|
||||
ID string `json:"id"`
|
||||
ContactID string `json:"contactId"`
|
||||
TaskID string `json:"taskId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ContactEventLink links a contact to an event
|
||||
type ContactEventLink struct {
|
||||
ID string `json:"id"`
|
||||
ContactID string `json:"contactId"`
|
||||
EventID string `json:"eventId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// InboxItem represents a quick capture item
|
||||
type InboxItem struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
Processed bool `json:"processed"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
ProcessedEntityType *string `json:"processedEntityType,omitempty"`
|
||||
ProcessedEntityID *string `json:"processedEntityId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// TimeEntry represents logged time
|
||||
type TimeEntry struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
TaskID *string `json:"taskId,omitempty"`
|
||||
Description string `json:"description"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SavedView represents a user's saved filter/view
|
||||
type SavedView struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
EntityType string `json:"entityType"`
|
||||
FilterJSON string `json:"filterJson"`
|
||||
SortJSON string `json:"sortJson"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CreateContactInput for creating contacts
|
||||
type CreateContactInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
CompanyID *string `json:"companyId"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// UpdateContactInput for updating contacts
|
||||
type UpdateContactInput struct {
|
||||
FirstName *string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
CompanyID *string `json:"companyId"`
|
||||
Title *string `json:"title"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// CreateCompanyInput for creating companies
|
||||
type CreateCompanyInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Domain string `json:"domain"`
|
||||
Website string `json:"website"`
|
||||
Industry string `json:"industry"`
|
||||
Size string `json:"size"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// UpdateCompanyInput for updating companies
|
||||
type UpdateCompanyInput struct {
|
||||
Name *string `json:"name"`
|
||||
Domain *string `json:"domain"`
|
||||
Website *string `json:"website"`
|
||||
Industry *string `json:"industry"`
|
||||
Size *string `json:"size"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// CreateInboxItemInput for quick capture
|
||||
type CreateInboxItemInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// CreateTimeEntryInput for time tracking
|
||||
type CreateTimeEntryInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
TaskID *string `json:"taskId"`
|
||||
Description string `json:"description"`
|
||||
StartedAt time.Time `json:"startedAt" binding:"required"`
|
||||
EndedAt *time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// UpdateTimeEntryInput for updating time entries
|
||||
type UpdateTimeEntryInput struct {
|
||||
Description *string `json:"description"`
|
||||
EndedAt *time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// CreateSavedViewInput for saved views
|
||||
type CreateSavedViewInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
EntityType string `json:"entityType" binding:"required"`
|
||||
FilterJSON string `json:"filterJson"`
|
||||
SortJSON string `json:"sortJson"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Contact methods
|
||||
|
||||
func (s *PostgresStore) ListContacts(workspaceSlug string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE c.workspace_slug = $1
|
||||
ORDER BY c.updated_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetContactByID(contactID string) (Contact, error) {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE c.id = $1
|
||||
`, contactID).Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateContact(input CreateContactInput) Contact {
|
||||
now := time.Now().UTC()
|
||||
c := Contact{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
Phone: input.Phone,
|
||||
CompanyID: input.CompanyID,
|
||||
Title: input.Title,
|
||||
Notes: input.Notes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO contacts (id, workspace_slug, first_name, last_name, email, phone,
|
||||
company_id, title, notes, avatar_url, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, '', $10, $11)
|
||||
`, c.ID, c.WorkspaceSlug, c.FirstName, c.LastName, c.Email, c.Phone,
|
||||
ptrToNullString(c.CompanyID), c.Title, c.Notes, c.CreatedAt, c.UpdatedAt)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateContact(contactID string, input UpdateContactInput) (Contact, error) {
|
||||
c, err := s.GetContactByID(contactID)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if input.FirstName != nil {
|
||||
c.FirstName = *input.FirstName
|
||||
}
|
||||
if input.LastName != nil {
|
||||
c.LastName = *input.LastName
|
||||
}
|
||||
if input.Email != nil {
|
||||
c.Email = *input.Email
|
||||
}
|
||||
if input.Phone != nil {
|
||||
c.Phone = *input.Phone
|
||||
}
|
||||
if input.CompanyID != nil {
|
||||
c.CompanyID = input.CompanyID
|
||||
}
|
||||
if input.Title != nil {
|
||||
c.Title = *input.Title
|
||||
}
|
||||
if input.Notes != nil {
|
||||
c.Notes = *input.Notes
|
||||
}
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE contacts SET first_name = $1, last_name = $2, email = $3, phone = $4,
|
||||
company_id = $5, title = $6, notes = $7, updated_at = $8
|
||||
WHERE id = $9
|
||||
`, c.FirstName, c.LastName, c.Email, c.Phone, ptrToNullString(c.CompanyID),
|
||||
c.Title, c.Notes, c.UpdatedAt, c.ID)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteContact(contactID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM contacts WHERE id = $1`, contactID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Company methods
|
||||
|
||||
func (s *PostgresStore) ListCompanies(workspaceSlug string) []Company {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
|
||||
FROM companies
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY name ASC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var companies []Company
|
||||
for rows.Next() {
|
||||
var c Company
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
|
||||
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
companies = append(companies, c)
|
||||
}
|
||||
return companies
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetCompanyByID(companyID string) (Company, error) {
|
||||
var c Company
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
|
||||
FROM companies
|
||||
WHERE id = $1
|
||||
`, companyID).Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
|
||||
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt)
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateCompany(input CreateCompanyInput) Company {
|
||||
now := time.Now().UTC()
|
||||
c := Company{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
Domain: input.Domain,
|
||||
Website: input.Website,
|
||||
Industry: input.Industry,
|
||||
Size: input.Size,
|
||||
Notes: input.Notes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO companies (id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10)
|
||||
`, c.ID, c.WorkspaceSlug, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.CreatedAt, c.UpdatedAt)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error) {
|
||||
c, err := s.GetCompanyByID(companyID)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
c.Name = *input.Name
|
||||
}
|
||||
if input.Domain != nil {
|
||||
c.Domain = *input.Domain
|
||||
}
|
||||
if input.Website != nil {
|
||||
c.Website = *input.Website
|
||||
}
|
||||
if input.Industry != nil {
|
||||
c.Industry = *input.Industry
|
||||
}
|
||||
if input.Size != nil {
|
||||
c.Size = *input.Size
|
||||
}
|
||||
if input.Notes != nil {
|
||||
c.Notes = *input.Notes
|
||||
}
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE companies SET name = $1, domain = $2, website = $3, industry = $4, size = $5, notes = $6, updated_at = $7
|
||||
WHERE id = $8
|
||||
`, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.UpdatedAt, c.ID)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteCompany(companyID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM companies WHERE id = $1`, companyID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Contact-Task linking
|
||||
|
||||
func (s *PostgresStore) LinkContactToTask(contactID, taskID string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO contact_tasks (id, contact_id, task_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (contact_id, task_id) DO NOTHING
|
||||
`, uuid.NewString(), contactID, taskID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UnlinkContactFromTask(contactID, taskID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM contact_tasks WHERE contact_id = $1 AND task_id = $2`, contactID, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListContactsForTask(taskID string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
JOIN contact_tasks ct ON c.id = ct.contact_id
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE ct.task_id = $1
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
// Contact-Event linking
|
||||
|
||||
func (s *PostgresStore) LinkContactToEvent(contactID, eventID string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO contact_events (id, contact_id, event_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (contact_id, event_id) DO NOTHING
|
||||
`, uuid.NewString(), contactID, eventID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListContactsForEvent(eventID string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
JOIN contact_events ce ON c.id = ce.contact_id
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE ce.event_id = $1
|
||||
`, eventID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func nullStringToPtr(ns sql.NullString) *string {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
return &ns.String
|
||||
}
|
||||
|
||||
func ptrToNullString(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
// Inbox methods
|
||||
|
||||
func (s *PostgresStore) ListInboxItems(workspaceSlug string) []InboxItem {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, content, source, processed, processed_at,
|
||||
processed_entity_type, processed_entity_id, created_at
|
||||
FROM inbox_items
|
||||
WHERE workspace_slug = $1 AND processed = false
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []InboxItem
|
||||
for rows.Next() {
|
||||
var item InboxItem
|
||||
var processedAt sql.NullTime
|
||||
var processedEntityType, processedEntityID sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.WorkspaceSlug, &item.Content, &item.Source,
|
||||
&item.Processed, &processedAt, &processedEntityType, &processedEntityID,
|
||||
&item.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
item.ProcessedAt = nullTimeToPtr(processedAt)
|
||||
item.ProcessedEntityType = nullStringToPtr(processedEntityType)
|
||||
item.ProcessedEntityID = nullStringToPtr(processedEntityID)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateInboxItem(input CreateInboxItemInput) InboxItem {
|
||||
now := time.Now().UTC()
|
||||
item := InboxItem{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Content: input.Content,
|
||||
Source: input.Source,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if item.Source == "" {
|
||||
item.Source = "manual"
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO inbox_items (id, workspace_slug, content, source, processed, created_at)
|
||||
VALUES ($1, $2, $3, $4, false, $5)
|
||||
`, item.ID, item.WorkspaceSlug, item.Content, item.Source, item.CreatedAt)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ProcessInboxItem(itemID string, entityType, entityID string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE inbox_items
|
||||
SET processed = true, processed_at = $1, processed_entity_type = $2, processed_entity_id = $3
|
||||
WHERE id = $4
|
||||
`, now, entityType, entityID, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteInboxItem(itemID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM inbox_items WHERE id = $1`, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Time entry methods
|
||||
|
||||
func (s *PostgresStore) ListTimeEntries(workspaceSlug string) []TimeEntry {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
|
||||
FROM time_entries
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY started_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []TimeEntry
|
||||
for rows.Next() {
|
||||
var e TimeEntry
|
||||
var taskID sql.NullString
|
||||
var endedAt sql.NullTime
|
||||
if err := rows.Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description,
|
||||
&e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
e.TaskID = nullStringToPtr(taskID)
|
||||
e.EndedAt = nullTimeToPtr(endedAt)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateTimeEntry(input CreateTimeEntryInput) TimeEntry {
|
||||
now := time.Now().UTC()
|
||||
e := TimeEntry{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
TaskID: input.TaskID,
|
||||
Description: input.Description,
|
||||
StartedAt: input.StartedAt,
|
||||
EndedAt: input.EndedAt,
|
||||
DurationSeconds: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if input.EndedAt != nil {
|
||||
e.DurationSeconds = int(input.EndedAt.Sub(input.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO time_entries (id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, e.ID, e.WorkspaceSlug, ptrToNullString(e.TaskID), e.Description, e.StartedAt,
|
||||
ptrToNullTime(e.EndedAt), e.DurationSeconds, e.CreatedAt, e.UpdatedAt)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error) {
|
||||
var e TimeEntry
|
||||
var taskID sql.NullString
|
||||
var endedAt sql.NullTime
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
|
||||
FROM time_entries WHERE id = $1
|
||||
`, entryID).Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description, &e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.TaskID = nullStringToPtr(taskID)
|
||||
e.EndedAt = nullTimeToPtr(endedAt)
|
||||
|
||||
if input.Description != nil {
|
||||
e.Description = *input.Description
|
||||
}
|
||||
if input.EndedAt != nil {
|
||||
e.EndedAt = input.EndedAt
|
||||
e.DurationSeconds = int(input.EndedAt.Sub(e.StartedAt).Seconds())
|
||||
}
|
||||
e.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE time_entries SET description = $1, ended_at = $2, duration_seconds = $3, updated_at = $4
|
||||
WHERE id = $5
|
||||
`, e.Description, ptrToNullTime(e.EndedAt), e.DurationSeconds, e.UpdatedAt, e.ID)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteTimeEntry(entryID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM time_entries WHERE id = $1`, entryID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Saved view methods
|
||||
|
||||
func (s *PostgresStore) ListSavedViews(workspaceSlug, entityType string) []SavedView {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at
|
||||
FROM saved_views
|
||||
WHERE workspace_slug = $1 AND entity_type = $2
|
||||
ORDER BY name ASC
|
||||
`, workspaceSlug, entityType)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var views []SavedView
|
||||
for rows.Next() {
|
||||
var v SavedView
|
||||
if err := rows.Scan(&v.ID, &v.WorkspaceSlug, &v.Name, &v.EntityType,
|
||||
&v.FilterJSON, &v.SortJSON, &v.IsDefault, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateSavedView(input CreateSavedViewInput) SavedView {
|
||||
now := time.Now().UTC()
|
||||
v := SavedView{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
EntityType: input.EntityType,
|
||||
FilterJSON: input.FilterJSON,
|
||||
SortJSON: input.SortJSON,
|
||||
IsDefault: input.IsDefault,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO saved_views (id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, v.ID, v.WorkspaceSlug, v.Name, v.EntityType, v.FilterJSON, v.SortJSON, v.IsDefault, v.CreatedAt, v.UpdatedAt)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteSavedView(viewID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM saved_views WHERE id = $1`, viewID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullTimeToPtr(nt sql.NullTime) *time.Time {
|
||||
if !nt.Valid {
|
||||
return nil
|
||||
}
|
||||
return &nt.Time
|
||||
}
|
||||
|
||||
func ptrToNullTime(t *time.Time) sql.NullTime {
|
||||
if t == nil {
|
||||
return sql.NullTime{Valid: false}
|
||||
}
|
||||
return sql.NullTime{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
// Ensure PostgresStore implements Store interface for new methods
|
||||
var _ Store = (*PostgresStore)(nil)
|
||||
|
||||
// Add interface methods to store.go
|
||||
// These will be added to the Store interface in store.go
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Integration represents an external service connection
|
||||
type Integration struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Config string `json:"config"`
|
||||
Status string `json:"status"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Webhook represents an external webhook endpoint
|
||||
type Webhook struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Events string `json:"events"` // JSON array
|
||||
Active bool `json:"active"`
|
||||
LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Notification represents a user notification
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
EntityType *string `json:"entityType,omitempty"`
|
||||
EntityID *string `json:"entityId,omitempty"`
|
||||
Read bool `json:"read"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence represents a user's real-time presence
|
||||
type Presence struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
UserName string `json:"userName"`
|
||||
EntityType *string `json:"entityType,omitempty"`
|
||||
EntityID *string `json:"entityId,omitempty"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateIntegrationInput for creating integrations
|
||||
type CreateIntegrationInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config string `json:"config"`
|
||||
Credentials string `json:"credentials" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateWebhookInput for creating webhooks
|
||||
type CreateWebhookInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Events string `json:"events"` // JSON array
|
||||
}
|
||||
|
||||
// CreateNotificationInput for creating notifications
|
||||
type CreateNotificationInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
UserEmail string `json:"userEmail" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
EntityType *string `json:"entityType"`
|
||||
EntityID *string `json:"entityId"`
|
||||
}
|
||||
|
||||
// UpdatePresenceInput for updating presence
|
||||
type UpdatePresenceInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
UserEmail string `json:"userEmail" binding:"required"`
|
||||
UserName string `json:"userName" binding:"required"`
|
||||
EntityType *string `json:"entityType"`
|
||||
EntityID *string `json:"entityId"`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Integration methods
|
||||
|
||||
func (s *PostgresStore) ListIntegrations(workspaceSlug string) []Integration {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
|
||||
FROM integrations
|
||||
WHERE workspace_slug = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var integrations []Integration
|
||||
for rows.Next() {
|
||||
var i Integration
|
||||
var lastSync sql.NullTime
|
||||
if err := rows.Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
|
||||
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
i.LastSyncAt = nullTimeToPtr(lastSync)
|
||||
integrations = append(integrations, i)
|
||||
}
|
||||
return integrations
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetIntegrationByID(integrationID string) (Integration, error) {
|
||||
var i Integration
|
||||
var lastSync sql.NullTime
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
|
||||
FROM integrations WHERE id = $1
|
||||
`, integrationID).Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
|
||||
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt)
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
i.LastSyncAt = nullTimeToPtr(lastSync)
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateIntegration(input CreateIntegrationInput) Integration {
|
||||
now := time.Now().UTC()
|
||||
i := Integration{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Provider: input.Provider,
|
||||
Name: input.Name,
|
||||
Config: input.Config,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO integrations (id, workspace_slug, provider, name, config, credentials_ciphertext, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)
|
||||
`, i.ID, i.WorkspaceSlug, i.Provider, i.Name, i.Config, input.Credentials, i.CreatedAt, i.UpdatedAt)
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteIntegration(integrationID string) error {
|
||||
_, err := s.db.Exec(`UPDATE integrations SET status = 'deleted', updated_at = $1 WHERE id = $2`, time.Now().UTC(), integrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Webhook methods
|
||||
|
||||
func (s *PostgresStore) ListWebhooks(workspaceSlug string) []Webhook {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, url, events, active, last_triggered_at, created_at, updated_at
|
||||
FROM webhooks
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var webhooks []Webhook
|
||||
for rows.Next() {
|
||||
var w Webhook
|
||||
var lastTriggered sql.NullTime
|
||||
if err := rows.Scan(&w.ID, &w.WorkspaceSlug, &w.Name, &w.URL, &w.Events,
|
||||
&w.Active, &lastTriggered, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
w.LastTriggeredAt = nullTimeToPtr(lastTriggered)
|
||||
webhooks = append(webhooks, w)
|
||||
}
|
||||
return webhooks
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateWebhook(input CreateWebhookInput) Webhook {
|
||||
now := time.Now().UTC()
|
||||
w := Webhook{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
URL: input.URL,
|
||||
Events: input.Events,
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if w.Events == "" {
|
||||
w.Events = "[]"
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO webhooks (id, workspace_slug, name, url, secret, events, active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, w.ID, w.WorkspaceSlug, w.Name, w.URL, uuid.NewString(), w.Events, w.Active, w.CreatedAt, w.UpdatedAt)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteWebhook(webhookID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM webhooks WHERE id = $1`, webhookID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Notification methods
|
||||
|
||||
func (s *PostgresStore) ListNotifications(userEmail string, limit int) []Notification {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at
|
||||
FROM notifications
|
||||
WHERE user_email = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, userEmail, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notifications []Notification
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var entityType, entityID sql.NullString
|
||||
if err := rows.Scan(&n.ID, &n.WorkspaceSlug, &n.UserEmail, &n.Type, &n.Title,
|
||||
&n.Body, &entityType, &entityID, &n.Read, &n.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.EntityType = nullStringToPtr(entityType)
|
||||
n.EntityID = nullStringToPtr(entityID)
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
return notifications
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateNotification(input CreateNotificationInput) Notification {
|
||||
n := Notification{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
UserEmail: input.UserEmail,
|
||||
Type: input.Type,
|
||||
Title: input.Title,
|
||||
Body: input.Body,
|
||||
EntityType: input.EntityType,
|
||||
EntityID: input.EntityID,
|
||||
Read: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO notifications (id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9)
|
||||
`, n.ID, n.WorkspaceSlug, n.UserEmail, n.Type, n.Title, n.Body,
|
||||
ptrToNullString(n.EntityType), ptrToNullString(n.EntityID), n.CreatedAt)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkNotificationRead(notificationID string) error {
|
||||
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE id = $1`, notificationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkAllNotificationsRead(userEmail string) error {
|
||||
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE user_email = $1 AND read = false`, userEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UnreadNotificationCount(userEmail string) int {
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_email = $1 AND read = false`, userEmail).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// Presence methods
|
||||
|
||||
func (s *PostgresStore) UpdatePresence(input UpdatePresenceInput) Presence {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Upsert presence
|
||||
s.db.Exec(`
|
||||
INSERT INTO presence (id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_slug, user_email) DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
entity_id = EXCLUDED.entity_id,
|
||||
last_seen_at = EXCLUDED.last_seen_at
|
||||
`, uuid.NewString(), input.WorkspaceSlug, input.UserEmail, input.UserName,
|
||||
ptrToNullString(input.EntityType), ptrToNullString(input.EntityID), now, now)
|
||||
|
||||
return Presence{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
UserEmail: input.UserEmail,
|
||||
UserName: input.UserName,
|
||||
EntityType: input.EntityType,
|
||||
EntityID: input.EntityID,
|
||||
LastSeenAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListPresence(workspaceSlug string, entityType, entityID string) []Presence {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at
|
||||
FROM presence
|
||||
WHERE workspace_slug = $1
|
||||
AND last_seen_at > $2
|
||||
AND ($3 = '' OR entity_type = $3)
|
||||
AND ($4 = '' OR entity_id = $4)
|
||||
ORDER BY last_seen_at DESC
|
||||
`, workspaceSlug, time.Now().Add(-5*time.Minute), entityType, entityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var presences []Presence
|
||||
for rows.Next() {
|
||||
var p Presence
|
||||
var entityType, entityID sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.WorkspaceSlug, &p.UserEmail, &p.UserName,
|
||||
&entityType, &entityID, &p.LastSeenAt, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
p.EntityType = nullStringToPtr(entityType)
|
||||
p.EntityID = nullStringToPtr(entityID)
|
||||
presences = append(presences, p)
|
||||
}
|
||||
return presences
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ClearPresence(workspaceSlug, userEmail string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM presence WHERE workspace_slug = $1 AND user_email = $2`, workspaceSlug, userEmail)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Label string `json:"label"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IMAPHost string `json:"imapHost"`
|
||||
IMAPPort int `json:"imapPort"`
|
||||
IMAPUsername string `json:"imapUsername"`
|
||||
IMAPUseTLS bool `json:"imapUseTls"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort int `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPUseTLS bool `json:"smtpUseTls"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
SyncStatus string `json:"syncStatus"`
|
||||
SyncError string `json:"syncError,omitempty"`
|
||||
}
|
||||
|
||||
type MailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
RemoteUID int64 `json:"remoteUid"`
|
||||
MessageID string `json:"messageId"`
|
||||
Folder string `json:"folder"`
|
||||
From MailAddress `json:"from"`
|
||||
To []MailAddress `json:"to"`
|
||||
Cc []MailAddress `json:"cc"`
|
||||
Subject string `json:"subject"`
|
||||
Snippet string `json:"snippet"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
IsRead bool `json:"isRead"`
|
||||
LinkedTaskID *string `json:"linkedTaskId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type OutgoingMail struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
To []MailAddress `json:"to"`
|
||||
Cc []MailAddress `json:"cc"`
|
||||
Bcc []MailAddress `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
Status string `json:"status"`
|
||||
ScheduledFor *time.Time `json:"scheduledFor,omitempty"`
|
||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type MailboxConnection struct {
|
||||
Mailbox
|
||||
IMAPPasswordCiphertext string
|
||||
SMTPPasswordCiphertext string
|
||||
}
|
||||
|
||||
type CreateMailboxRecordInput struct {
|
||||
WorkspaceSlug string
|
||||
Label string
|
||||
Email string
|
||||
DisplayName string
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPUsername string
|
||||
IMAPPasswordCiphertext string
|
||||
IMAPUseTLS bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPasswordCiphertext string
|
||||
SMTPUseTLS bool
|
||||
}
|
||||
|
||||
type UpdateMailboxSyncStatusInput struct {
|
||||
SyncStatus string
|
||||
SyncError *string
|
||||
LastSyncedAt *time.Time
|
||||
}
|
||||
|
||||
type InboundMailMessage struct {
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
RemoteUID int64
|
||||
MessageID string
|
||||
Folder string
|
||||
From MailAddress
|
||||
To []MailAddress
|
||||
Cc []MailAddress
|
||||
Subject string
|
||||
Snippet string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
ReceivedAt time.Time
|
||||
IsRead bool
|
||||
}
|
||||
|
||||
type CreateOutgoingMailInput struct {
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
To []MailAddress
|
||||
Cc []MailAddress
|
||||
Bcc []MailAddress
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
Status string
|
||||
ScheduledFor *time.Time
|
||||
}
|
||||
|
||||
type UpdateOutgoingMailStatusInput struct {
|
||||
Status string
|
||||
SentAt *time.Time
|
||||
Error *string
|
||||
}
|
||||
|
||||
func (s *State) ListAllMailboxes() []Mailbox {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]Mailbox(nil), s.Mailboxes...)
|
||||
}
|
||||
|
||||
func (s *State) ListMailboxes(workspaceSlug string) []Mailbox {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return filterByWorkspace(s.Mailboxes, workspaceSlug)
|
||||
}
|
||||
|
||||
func (s *State) GetMailboxByID(mailboxID string) (Mailbox, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, mailbox := range s.Mailboxes {
|
||||
if mailbox.ID == mailboxID {
|
||||
return mailbox, nil
|
||||
}
|
||||
}
|
||||
|
||||
return Mailbox{}, errors.New("mailbox not found")
|
||||
}
|
||||
|
||||
func (s *State) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
connection, ok := s.MailboxAuth[mailboxID]
|
||||
if !ok {
|
||||
return MailboxConnection{}, errors.New("mailbox connection not found")
|
||||
}
|
||||
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func (s *State) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
mailbox := Mailbox{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Label: input.Label,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
IMAPHost: input.IMAPHost,
|
||||
IMAPPort: input.IMAPPort,
|
||||
IMAPUsername: input.IMAPUsername,
|
||||
IMAPUseTLS: input.IMAPUseTLS,
|
||||
SMTPHost: input.SMTPHost,
|
||||
SMTPPort: input.SMTPPort,
|
||||
SMTPUsername: input.SMTPUsername,
|
||||
SMTPUseTLS: input.SMTPUseTLS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "idle",
|
||||
}
|
||||
s.Mailboxes = append([]Mailbox{mailbox}, s.Mailboxes...)
|
||||
s.MailboxAuth[mailbox.ID] = MailboxConnection{
|
||||
Mailbox: mailbox,
|
||||
IMAPPasswordCiphertext: input.IMAPPasswordCiphertext,
|
||||
SMTPPasswordCiphertext: input.SMTPPasswordCiphertext,
|
||||
}
|
||||
s.appendActivityLocked(input.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", input.Email))
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func (s *State) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, mailbox := range s.Mailboxes {
|
||||
if mailbox.ID != mailboxID {
|
||||
continue
|
||||
}
|
||||
|
||||
if input.SyncStatus != "" {
|
||||
mailbox.SyncStatus = input.SyncStatus
|
||||
}
|
||||
if input.SyncError != nil {
|
||||
mailbox.SyncError = *input.SyncError
|
||||
}
|
||||
if input.LastSyncedAt != nil {
|
||||
mailbox.LastSyncedAt = input.LastSyncedAt
|
||||
}
|
||||
mailbox.UpdatedAt = time.Now().UTC()
|
||||
s.Mailboxes[index] = mailbox
|
||||
if connection, ok := s.MailboxAuth[mailboxID]; ok {
|
||||
connection.Mailbox = mailbox
|
||||
s.MailboxAuth[mailboxID] = connection
|
||||
}
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
return Mailbox{}, errors.New("mailbox not found")
|
||||
}
|
||||
|
||||
func (s *State) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := filterByWorkspace(s.MailMessages, workspaceSlug)
|
||||
if mailboxID == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
filtered := make([]MailMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.MailboxID == mailboxID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *State) GetMailMessageByID(messageID string) (MailMessage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, message := range s.MailMessages {
|
||||
if message.ID == messageID {
|
||||
return message, nil
|
||||
}
|
||||
}
|
||||
|
||||
return MailMessage{}, errors.New("mail message not found")
|
||||
}
|
||||
|
||||
func (s *State) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, input := range messages {
|
||||
found := false
|
||||
for index, message := range s.MailMessages {
|
||||
if message.MailboxID == mailboxID && message.Folder == input.Folder && message.RemoteUID == input.RemoteUID {
|
||||
linkedTaskID := message.LinkedTaskID
|
||||
s.MailMessages[index] = MailMessage{
|
||||
ID: message.ID,
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: mailboxID,
|
||||
RemoteUID: input.RemoteUID,
|
||||
MessageID: input.MessageID,
|
||||
Folder: input.Folder,
|
||||
From: input.From,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Subject: input.Subject,
|
||||
Snippet: input.Snippet,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ReceivedAt: input.ReceivedAt,
|
||||
IsRead: input.IsRead,
|
||||
LinkedTaskID: linkedTaskID,
|
||||
CreatedAt: message.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
s.MailMessages = append([]MailMessage{{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: mailboxID,
|
||||
RemoteUID: input.RemoteUID,
|
||||
MessageID: input.MessageID,
|
||||
Folder: input.Folder,
|
||||
From: input.From,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Subject: input.Subject,
|
||||
Snippet: input.Snippet,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ReceivedAt: input.ReceivedAt,
|
||||
IsRead: input.IsRead,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}}, s.MailMessages...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, message := range s.MailMessages {
|
||||
if message.ID != messageID {
|
||||
continue
|
||||
}
|
||||
message.LinkedTaskID = &taskID
|
||||
message.UpdatedAt = time.Now().UTC()
|
||||
s.MailMessages[index] = message
|
||||
return message, nil
|
||||
}
|
||||
|
||||
return MailMessage{}, errors.New("mail message not found")
|
||||
}
|
||||
|
||||
func (s *State) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := filterByWorkspace(s.OutgoingMails, workspaceSlug)
|
||||
if mailboxID == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
filtered := make([]OutgoingMail, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.MailboxID == mailboxID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *State) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := make([]OutgoingMail, 0, limit)
|
||||
for _, item := range s.OutgoingMails {
|
||||
if item.Status != "queued" && item.Status != "scheduled" {
|
||||
continue
|
||||
}
|
||||
if item.ScheduledFor != nil && item.ScheduledFor.After(now) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if limit > 0 && len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *State) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, item := range s.OutgoingMails {
|
||||
if item.ID == outgoingMailID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
|
||||
return OutgoingMail{}, errors.New("outgoing mail not found")
|
||||
}
|
||||
|
||||
func (s *State) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
item := OutgoingMail{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: input.MailboxID,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Bcc: input.Bcc,
|
||||
Subject: input.Subject,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
Status: input.Status,
|
||||
ScheduledFor: input.ScheduledFor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.OutgoingMails = append([]OutgoingMail{item}, s.OutgoingMails...)
|
||||
s.appendActivityLocked(input.WorkspaceSlug, "Outgoing mail queued", input.Subject)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *State) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, item := range s.OutgoingMails {
|
||||
if item.ID != outgoingMailID {
|
||||
continue
|
||||
}
|
||||
if input.Status != "" {
|
||||
item.Status = input.Status
|
||||
}
|
||||
if input.SentAt != nil {
|
||||
item.SentAt = input.SentAt
|
||||
}
|
||||
if input.Error != nil {
|
||||
item.Error = *input.Error
|
||||
}
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
s.OutgoingMails[index] = item
|
||||
return item, nil
|
||||
}
|
||||
|
||||
return OutgoingMail{}, errors.New("outgoing mail not found")
|
||||
}
|
||||
|
||||
func (item Mailbox) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
func (item MailMessage) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
func (item OutgoingMail) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
@@ -0,0 +1,550 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *PostgresStore) ListAllMailboxes() []Mailbox {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailboxes(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListMailboxes(workspaceSlug string) []Mailbox {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY updated_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailboxes(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailboxByID(mailboxID string) (Mailbox, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
WHERE id = $1
|
||||
`, mailboxID)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
|
||||
var (
|
||||
connection MailboxConnection
|
||||
lastSynced sql.NullTime
|
||||
syncError sql.NullString
|
||||
)
|
||||
|
||||
err := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error,
|
||||
imap_password_ciphertext, smtp_password_ciphertext
|
||||
FROM mailboxes
|
||||
WHERE id = $1
|
||||
`, mailboxID).Scan(
|
||||
&connection.ID,
|
||||
&connection.WorkspaceSlug,
|
||||
&connection.Label,
|
||||
&connection.Email,
|
||||
&connection.DisplayName,
|
||||
&connection.IMAPHost,
|
||||
&connection.IMAPPort,
|
||||
&connection.IMAPUsername,
|
||||
&connection.IMAPUseTLS,
|
||||
&connection.SMTPHost,
|
||||
&connection.SMTPPort,
|
||||
&connection.SMTPUsername,
|
||||
&connection.SMTPUseTLS,
|
||||
&connection.CreatedAt,
|
||||
&connection.UpdatedAt,
|
||||
&lastSynced,
|
||||
&connection.SyncStatus,
|
||||
&syncError,
|
||||
&connection.IMAPPasswordCiphertext,
|
||||
&connection.SMTPPasswordCiphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return MailboxConnection{}, err
|
||||
}
|
||||
|
||||
connection.LastSyncedAt = timePtr(lastSynced)
|
||||
connection.SyncError = syncError.String
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
|
||||
now := time.Now().UTC()
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mailboxes (
|
||||
id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_password_ciphertext, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_password_ciphertext, smtp_use_tls, sync_status, sync_error, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'idle', '', $16, $16)
|
||||
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
`,
|
||||
uuid.NewString(),
|
||||
input.WorkspaceSlug,
|
||||
defaultString(input.Label, input.Email),
|
||||
input.Email,
|
||||
input.DisplayName,
|
||||
input.IMAPHost,
|
||||
input.IMAPPort,
|
||||
input.IMAPUsername,
|
||||
input.IMAPPasswordCiphertext,
|
||||
input.IMAPUseTLS,
|
||||
input.SMTPHost,
|
||||
input.SMTPPort,
|
||||
input.SMTPUsername,
|
||||
input.SMTPPasswordCiphertext,
|
||||
input.SMTPUseTLS,
|
||||
now,
|
||||
)
|
||||
|
||||
mailbox, err := scanMailbox(row)
|
||||
if err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
|
||||
if err := appendActivity(context.Background(), s.queries, mailbox.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", mailbox.Email)); err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE mailboxes
|
||||
SET sync_status = COALESCE(NULLIF($2, ''), sync_status),
|
||||
sync_error = COALESCE($3, sync_error),
|
||||
last_synced_at = COALESCE($4, last_synced_at),
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
`, mailboxID, input.SyncStatus, nullableString(input.SyncError), nullableTime(input.LastSyncedAt), time.Now().UTC())
|
||||
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if mailboxID == "" {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE workspace_slug = $1 AND mailbox_id = $2
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug, mailboxID)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailMessages(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailMessageByID(messageID string) (MailMessage, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE id = $1
|
||||
`, messageID)
|
||||
return scanMailMessage(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
|
||||
ctx := context.Background()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, message := range messages {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mail_messages (
|
||||
id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, $16, $16)
|
||||
ON CONFLICT (mailbox_id, folder, remote_uid)
|
||||
DO UPDATE SET
|
||||
message_id = EXCLUDED.message_id,
|
||||
from_address = EXCLUDED.from_address,
|
||||
to_recipients = EXCLUDED.to_recipients,
|
||||
cc_recipients = EXCLUDED.cc_recipients,
|
||||
subject = EXCLUDED.subject,
|
||||
snippet = EXCLUDED.snippet,
|
||||
text_body = EXCLUDED.text_body,
|
||||
html_body = EXCLUDED.html_body,
|
||||
received_at = EXCLUDED.received_at,
|
||||
is_read = EXCLUDED.is_read,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
uuid.NewString(),
|
||||
message.WorkspaceSlug,
|
||||
mailboxID,
|
||||
message.RemoteUID,
|
||||
message.MessageID,
|
||||
defaultString(message.Folder, "INBOX"),
|
||||
mustJSON(message.From),
|
||||
mustJSON(message.To),
|
||||
mustJSON(message.Cc),
|
||||
message.Subject,
|
||||
message.Snippet,
|
||||
message.TextBody,
|
||||
message.HTMLBody,
|
||||
message.ReceivedAt,
|
||||
message.IsRead,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE mail_messages
|
||||
SET linked_task_id = $2, updated_at = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
`, messageID, taskID, time.Now().UTC())
|
||||
return scanMailMessage(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if mailboxID == "" {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE workspace_slug = $1 AND mailbox_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug, mailboxID)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanOutgoingMails(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE status IN ('queued', 'scheduled')
|
||||
AND (scheduled_for IS NULL OR scheduled_for <= $1)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2
|
||||
`, now, limit)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanOutgoingMails(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE id = $1
|
||||
`, outgoingMailID)
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
|
||||
now := time.Now().UTC()
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO outgoing_mails (
|
||||
id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, '', $12, $12)
|
||||
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
`,
|
||||
uuid.NewString(),
|
||||
input.WorkspaceSlug,
|
||||
input.MailboxID,
|
||||
mustJSON(input.To),
|
||||
mustJSON(input.Cc),
|
||||
mustJSON(input.Bcc),
|
||||
input.Subject,
|
||||
input.TextBody,
|
||||
input.HTMLBody,
|
||||
input.Status,
|
||||
nullableTime(input.ScheduledFor),
|
||||
now,
|
||||
)
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE outgoing_mails
|
||||
SET status = COALESCE(NULLIF($2, ''), status),
|
||||
sent_at = COALESCE($3, sent_at),
|
||||
error_message = COALESCE($4, error_message),
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
`, outgoingMailID, input.Status, nullableTime(input.SentAt), nullableString(input.Error), time.Now().UTC())
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanMailboxes(rows *sql.Rows) []Mailbox {
|
||||
items := make([]Mailbox, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanMailbox(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanMailbox(row rowScanner) (Mailbox, error) {
|
||||
var (
|
||||
item Mailbox
|
||||
lastSynced sql.NullTime
|
||||
syncError sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.Label,
|
||||
&item.Email,
|
||||
&item.DisplayName,
|
||||
&item.IMAPHost,
|
||||
&item.IMAPPort,
|
||||
&item.IMAPUsername,
|
||||
&item.IMAPUseTLS,
|
||||
&item.SMTPHost,
|
||||
&item.SMTPPort,
|
||||
&item.SMTPUsername,
|
||||
&item.SMTPUseTLS,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&lastSynced,
|
||||
&item.SyncStatus,
|
||||
&syncError,
|
||||
)
|
||||
if err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
item.LastSyncedAt = timePtr(lastSynced)
|
||||
item.SyncError = syncError.String
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanMailMessages(rows *sql.Rows) []MailMessage {
|
||||
items := make([]MailMessage, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanMailMessage(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanMailMessage(row rowScanner) (MailMessage, error) {
|
||||
var (
|
||||
item MailMessage
|
||||
fromJSON []byte
|
||||
toJSON []byte
|
||||
ccJSON []byte
|
||||
linkedTaskID sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.MailboxID,
|
||||
&item.RemoteUID,
|
||||
&item.MessageID,
|
||||
&item.Folder,
|
||||
&fromJSON,
|
||||
&toJSON,
|
||||
&ccJSON,
|
||||
&item.Subject,
|
||||
&item.Snippet,
|
||||
&item.TextBody,
|
||||
&item.HTMLBody,
|
||||
&item.ReceivedAt,
|
||||
&item.IsRead,
|
||||
&linkedTaskID,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
if err := decodeJSONValue(fromJSON, &item.From); err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
to, err := decodeJSONSlice[MailAddress](toJSON)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
cc, err := decodeJSONSlice[MailAddress](ccJSON)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
item.To = to
|
||||
item.Cc = cc
|
||||
item.LinkedTaskID = stringPtr(linkedTaskID)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanOutgoingMails(rows *sql.Rows) []OutgoingMail {
|
||||
items := make([]OutgoingMail, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanOutgoingMail(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanOutgoingMail(row rowScanner) (OutgoingMail, error) {
|
||||
var (
|
||||
item OutgoingMail
|
||||
toJSON []byte
|
||||
ccJSON []byte
|
||||
bccJSON []byte
|
||||
scheduledFor sql.NullTime
|
||||
sentAt sql.NullTime
|
||||
errorMessage sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.MailboxID,
|
||||
&toJSON,
|
||||
&ccJSON,
|
||||
&bccJSON,
|
||||
&item.Subject,
|
||||
&item.TextBody,
|
||||
&item.HTMLBody,
|
||||
&item.Status,
|
||||
&scheduledFor,
|
||||
&sentAt,
|
||||
&errorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
to, err := decodeJSONSlice[MailAddress](toJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
cc, err := decodeJSONSlice[MailAddress](ccJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
bcc, err := decodeJSONSlice[MailAddress](bccJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
item.To = to
|
||||
item.Cc = cc
|
||||
item.Bcc = bcc
|
||||
item.ScheduledFor = timePtr(scheduledFor)
|
||||
item.SentAt = timePtr(sentAt)
|
||||
item.Error = errorMessage.String
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte, target any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateNotificationForTaskAssignment creates a notification when a task is assigned
|
||||
func (s *PostgresStore) CreateNotificationForTaskAssignment(workspaceSlug, assigneeEmail, taskTitle, taskID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: assigneeEmail,
|
||||
Type: "task_assigned",
|
||||
Title: "Task assigned to you",
|
||||
Body: "You have been assigned to: " + taskTitle,
|
||||
EntityType: strPtr("task"),
|
||||
EntityID: strPtr(taskID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForMention creates a notification when a user is mentioned
|
||||
func (s *PostgresStore) CreateNotificationForMention(workspaceSlug, mentionedEmail, mentionerName, entityType, entityID, context string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: mentionedEmail,
|
||||
Type: "mention",
|
||||
Title: mentionerName + " mentioned you",
|
||||
Body: context,
|
||||
EntityType: strPtr(entityType),
|
||||
EntityID: strPtr(entityID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForComment creates a notification for a new comment on an entity
|
||||
func (s *PostgresStore) CreateNotificationForComment(workspaceSlug, ownerEmail, commenterName, entityType, entityID, entityTitle string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: ownerEmail,
|
||||
Type: "comment",
|
||||
Title: commenterName + " commented on " + entityTitle,
|
||||
Body: "New comment on " + entityType,
|
||||
EntityType: strPtr(entityType),
|
||||
EntityID: strPtr(entityID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForTaskCompletion creates a notification when a task is completed
|
||||
func (s *PostgresStore) CreateNotificationForTaskCompletion(workspaceSlug, assignerEmail, taskTitle, taskID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: assignerEmail,
|
||||
Type: "task_completed",
|
||||
Title: "Task completed: " + taskTitle,
|
||||
Body: "A task you assigned has been completed",
|
||||
EntityType: strPtr("task"),
|
||||
EntityID: strPtr(taskID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForEventReminder creates a notification for an upcoming event
|
||||
func (s *PostgresStore) CreateNotificationForEventReminder(workspaceSlug, userEmail, eventTitle, eventID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: userEmail,
|
||||
Type: "event_reminder",
|
||||
Title: "Upcoming event: " + eventTitle,
|
||||
Body: "Your event is starting soon",
|
||||
EntityType: strPtr("event"),
|
||||
EntityID: strPtr(eventID),
|
||||
})
|
||||
}
|
||||
|
||||
// TriggerWebhooks triggers all webhooks for a given event type
|
||||
func (s *PostgresStore) TriggerWebhooks(workspaceSlug, eventType string, payload map[string]interface{}) {
|
||||
// Get all active webhooks for this workspace
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, url, secret, events
|
||||
FROM webhooks
|
||||
WHERE workspace_slug = $1 AND active = true
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, url, secret, eventsJSON string
|
||||
if err := rows.Scan(&id, &url, &secret, &eventsJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this webhook subscribes to the event
|
||||
// Simple string contains check for the event type in the JSON array
|
||||
if !containsEvent(eventsJSON, eventType) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update last triggered timestamp
|
||||
s.db.Exec(`UPDATE webhooks SET last_triggered_at = $1 WHERE id = $2`, time.Now().UTC(), id)
|
||||
|
||||
// Webhook delivery would happen here in a goroutine
|
||||
// For now, we just mark it as triggered
|
||||
go deliverWebhook(url, secret, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func containsEvent(eventsJSON, eventType string) bool {
|
||||
// Simple check - in production would parse JSON properly
|
||||
return len(eventsJSON) > 2 &&
|
||||
(eventsJSON == "[]" ||
|
||||
eventsJSON == "[\""+eventType+"\"]" ||
|
||||
containsSubstring(eventsJSON, "\""+eventType+"\""))
|
||||
}
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deliverWebhook(url, secret, eventType string, payload map[string]interface{}) {
|
||||
// Webhook delivery implementation
|
||||
// In production, this would make an HTTP POST request
|
||||
// with proper signature using the secret
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationsDirUsesEnvOverrideWhenValid(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
customDir := filepath.Join(tempDir, "migrations")
|
||||
if err := os.MkdirAll(customDir, 0o755); err != nil {
|
||||
t.Fatalf("create temp migrations dir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DB_MIGRATIONS_DIR", customDir)
|
||||
if got := migrationsDir(); got != customDir {
|
||||
t.Fatalf("migrationsDir() = %q, want %q", got, customDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsDirFallsBackWhenEnvOverrideIsInvalid(t *testing.T) {
|
||||
t.Setenv("DB_MIGRATIONS_DIR", filepath.Join(t.TempDir(), "missing"))
|
||||
got := migrationsDir()
|
||||
if got == "" {
|
||||
t.Fatal("migrationsDir() should never return empty path")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStateUpdateMemberRejectsLastActiveOwnerChange(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
owner := findMemberByRole(t, state, "owner")
|
||||
|
||||
nextRole := "member"
|
||||
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole}); err == nil {
|
||||
t.Fatalf("expected last active owner demotion to fail")
|
||||
}
|
||||
|
||||
nextStatus := "removed"
|
||||
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Status: &nextStatus}); err == nil {
|
||||
t.Fatalf("expected last active owner deactivation to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateUpdateMemberAllowsOwnerChangeWhenAnotherOwnerExists(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
owner := findMemberByRole(t, state, "owner")
|
||||
|
||||
state.Members = append(state.Members, Member{
|
||||
ID: "member-owner-2",
|
||||
WorkspaceSlug: owner.WorkspaceSlug,
|
||||
Name: "Backup Owner",
|
||||
Email: "backup-owner@productier.app",
|
||||
Role: "owner",
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
nextRole := "admin"
|
||||
updated, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole})
|
||||
if err != nil {
|
||||
t.Fatalf("expected owner demotion to succeed with another active owner: %v", err)
|
||||
}
|
||||
if updated.Role != "admin" {
|
||||
t.Fatalf("expected updated role admin, got %s", updated.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRevokeInviteRules(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
if len(state.Invites) == 0 {
|
||||
t.Fatalf("seed state has no invites")
|
||||
}
|
||||
invite := state.Invites[0]
|
||||
|
||||
if err := state.RevokeInvite(invite.ID); err != nil {
|
||||
t.Fatalf("expected pending invite revoke to succeed: %v", err)
|
||||
}
|
||||
if _, err := state.GetInviteByID(invite.ID); err == nil {
|
||||
t.Fatalf("expected revoked invite to be absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRevokeInviteRejectedWhenAccepted(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
if len(state.Invites) == 0 {
|
||||
t.Fatalf("seed state has no invites")
|
||||
}
|
||||
invite := state.Invites[0]
|
||||
|
||||
if _, err := state.AcceptInvite(invite.Token, AcceptInviteInput{Name: "Taylor", Email: invite.Email}); err != nil {
|
||||
t.Fatalf("accept invite setup failed: %v", err)
|
||||
}
|
||||
if err := state.RevokeInvite(invite.ID); err == nil {
|
||||
t.Fatalf("expected revoke of accepted invite to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func findMemberByRole(t *testing.T, state *State, role string) Member {
|
||||
t.Helper()
|
||||
for _, member := range state.Members {
|
||||
if member.Role == role && member.Status == "active" {
|
||||
return member
|
||||
}
|
||||
}
|
||||
t.Fatalf("no active member with role %s", role)
|
||||
return Member{}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type Store interface {
|
||||
ListWorkspaces() []Workspace
|
||||
ListMembers(workspaceSlug string) []Member
|
||||
GetMemberByID(memberID string) (Member, error)
|
||||
UpdateMember(memberID string, input UpdateMemberInput) (Member, error)
|
||||
ListInvites(workspaceSlug string) []Invite
|
||||
GetInviteByID(inviteID string) (Invite, error)
|
||||
GetInviteByToken(token string) (Invite, error)
|
||||
CreateInvite(input CreateInviteInput) Invite
|
||||
RevokeInvite(inviteID string) error
|
||||
AcceptInvite(token string, input AcceptInviteInput) (Invite, error)
|
||||
ListActivities(workspaceSlug string) []ActivityEntry
|
||||
ListBoardGroups(workspaceSlug string) []BoardGroup
|
||||
GetBoardGroupByID(groupID string) (BoardGroup, error)
|
||||
CreateBoardGroup(input CreateBoardGroupInput) BoardGroup
|
||||
UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error)
|
||||
ListLabels(workspaceSlug string) []Label
|
||||
CreateLabel(input CreateLabelInput) Label
|
||||
ListTasks(workspaceSlug string) []Task
|
||||
GetTaskByID(taskID string) (Task, error)
|
||||
CreateTask(input CreateTaskInput) Task
|
||||
UpdateTask(taskID string, input UpdateTaskInput) (Task, error)
|
||||
ListEvents(workspaceSlug string) []CalendarEvent
|
||||
GetEventByID(eventID string) (CalendarEvent, error)
|
||||
CreateEvent(input CreateEventInput) CalendarEvent
|
||||
UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error)
|
||||
ListNotes(workspaceSlug string) []Note
|
||||
GetNoteByID(noteID string) (Note, error)
|
||||
CreateNote(input CreateNoteInput) Note
|
||||
UpdateNote(noteID string, input UpdateNoteInput) (Note, error)
|
||||
ListFocusSessions(workspaceSlug string) []FocusSession
|
||||
GetFocusSessionByID(sessionID string) (FocusSession, error)
|
||||
CreateFocusSession(input CreateFocusSessionInput) FocusSession
|
||||
UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error)
|
||||
ListAllMailboxes() []Mailbox
|
||||
ListMailboxes(workspaceSlug string) []Mailbox
|
||||
GetMailboxByID(mailboxID string) (Mailbox, error)
|
||||
GetMailboxConnection(mailboxID string) (MailboxConnection, error)
|
||||
CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error)
|
||||
UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error)
|
||||
ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage
|
||||
GetMailMessageByID(messageID string) (MailMessage, error)
|
||||
UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error
|
||||
LinkMailMessageTask(messageID string, taskID string) (MailMessage, error)
|
||||
ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail
|
||||
ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail
|
||||
GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error)
|
||||
CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error)
|
||||
UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error)
|
||||
// CRM
|
||||
ListContacts(workspaceSlug string) []Contact
|
||||
GetContactByID(contactID string) (Contact, error)
|
||||
CreateContact(input CreateContactInput) Contact
|
||||
UpdateContact(contactID string, input UpdateContactInput) (Contact, error)
|
||||
DeleteContact(contactID string) error
|
||||
ListCompanies(workspaceSlug string) []Company
|
||||
GetCompanyByID(companyID string) (Company, error)
|
||||
CreateCompany(input CreateCompanyInput) Company
|
||||
UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error)
|
||||
DeleteCompany(companyID string) error
|
||||
LinkContactToTask(contactID, taskID string) error
|
||||
UnlinkContactFromTask(contactID, taskID string) error
|
||||
ListContactsForTask(taskID string) []Contact
|
||||
LinkContactToEvent(contactID, eventID string) error
|
||||
ListContactsForEvent(eventID string) []Contact
|
||||
// Inbox
|
||||
ListInboxItems(workspaceSlug string) []InboxItem
|
||||
CreateInboxItem(input CreateInboxItemInput) InboxItem
|
||||
ProcessInboxItem(itemID string, entityType, entityID string) error
|
||||
DeleteInboxItem(itemID string) error
|
||||
// Time tracking
|
||||
ListTimeEntries(workspaceSlug string) []TimeEntry
|
||||
CreateTimeEntry(input CreateTimeEntryInput) TimeEntry
|
||||
UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error)
|
||||
DeleteTimeEntry(entryID string) error
|
||||
// Saved views
|
||||
ListSavedViews(workspaceSlug, entityType string) []SavedView
|
||||
CreateSavedView(input CreateSavedViewInput) SavedView
|
||||
DeleteSavedView(viewID string) error
|
||||
// Integrations
|
||||
ListIntegrations(workspaceSlug string) []Integration
|
||||
GetIntegrationByID(integrationID string) (Integration, error)
|
||||
CreateIntegration(input CreateIntegrationInput) Integration
|
||||
DeleteIntegration(integrationID string) error
|
||||
// Webhooks
|
||||
ListWebhooks(workspaceSlug string) []Webhook
|
||||
CreateWebhook(input CreateWebhookInput) Webhook
|
||||
DeleteWebhook(webhookID string) error
|
||||
// Notifications
|
||||
ListNotifications(userEmail string, limit int) []Notification
|
||||
CreateNotification(input CreateNotificationInput) Notification
|
||||
MarkNotificationRead(notificationID string) error
|
||||
MarkAllNotificationsRead(userEmail string) error
|
||||
UnreadNotificationCount(userEmail string) int
|
||||
// Presence
|
||||
UpdatePresence(input UpdatePresenceInput) Presence
|
||||
ListPresence(workspaceSlug string, entityType, entityID string) []Presence
|
||||
ClearPresence(workspaceSlug, userEmail string) error
|
||||
}
|
||||
Reference in New Issue
Block a user