first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+166
View File
@@ -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"`
}
+568
View File
@@ -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
}
+453
View File
@@ -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{}
}
+103
View File
@@ -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
}