axe/internal/repository/project_test.go

585 lines
24 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
package repository
import (
"context"
"errors"
"testing"
"time"
"building-mgmt/internal/models"
)
// Тесты модели «проект → объекты → мастера → заявки» (миграция 0004).
// Проверяем то, что держится на SQL, а не на хендлерах: чужие сущности
// неотличимы от несуществующих, членство мастера и принадлежность объекта
// ловятся репозиторием/БД.
// TestProjectOwnership — проект виден только своему клиенту, админской
// «пустой» выборке — любой; чужой неотличим от несуществующего.
func TestProjectOwnership(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
owner := createProfile(t, ctx, q, "client")
stranger := createProfile(t, ctx, q, "client")
p := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
if err := repo.CreateProject(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
// Дубликат имени у того же клиента — конфликт (под savepoint:
// ожидаемая ошибка не должна отравить остальную транзакцию теста).
dup := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
expectError(t, ctx, q, "duplicate project name",
func() error { return repo.CreateProject(ctx, dup) }, ErrConflict)
// То же имя у другого клиента — норма (свои пространства имён).
other := &models.Project{ClientID: stranger.ID, Name: "ЖК Север", IsActive: true}
if err := repo.CreateProject(ctx, other); err != nil {
t.Errorf("same name for another client: err = %v, want nil", err)
}
// Своё лицо — находит, чужое — нет, админская выборка — находит.
if _, err := repo.OwnedProject(ctx, p.ID, owner.ID); err != nil {
t.Errorf("owner access: err = %v, want nil", err)
}
if _, err := repo.OwnedProject(ctx, p.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
}
if _, err := repo.OwnedProject(ctx, p.ID, ""); err != nil {
t.Errorf("admin access: err = %v, want nil", err)
}
// Списки: клиент видит только своё.
own, total, err := repo.ListProjects(ctx, owner.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list own: %v", err)
}
if len(own) != 1 || own[0].ID != p.ID {
t.Errorf("owner list = %v, want only project %d", projectIDs(own), p.ID)
}
if total != 1 {
t.Errorf("owner total = %d, want 1", total)
}
otherList, _, err := repo.ListProjects(ctx, stranger.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list stranger: %v", err)
}
if len(otherList) != 1 || otherList[0].ID != other.ID {
t.Errorf("stranger list = %v, want only own project", projectIDs(otherList))
}
}
// TestObjectBelongsToProject — объект живёт в проекте: чужой клиент его не
// читает, а составной FK не даёт сшить заявку из объекта и проекта
// разных контуров.
func TestObjectBelongsToProject(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
owner := createProfile(t, ctx, q, "client")
stranger := createProfile(t, ctx, q, "client")
p := &models.Project{ClientID: owner.ID, Name: "БЦ Альфа", IsActive: true}
if err := repo.CreateProject(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
o := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 5", IsActive: true}
if err := repo.CreateObject(ctx, o); err != nil {
t.Fatalf("create object: %v", err)
}
// Дубликат имени объекта в проекте — конфликт (под savepoint).
dup := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 6", IsActive: true}
expectError(t, ctx, q, "duplicate object name",
func() error { return repo.CreateObject(ctx, dup) }, ErrConflict)
if _, err := repo.OwnedObject(ctx, o.ID, owner.ID); err != nil {
t.Errorf("owner access: err = %v, want nil", err)
}
if _, err := repo.OwnedObject(ctx, o.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
}
// Правка адреса меняет объект; новые заявки возьмут новый адрес.
newAddr := "ул. Мира, 5а"
if err := repo.UpdateObject(ctx, o.ID, nil, &newAddr, nil); err != nil {
t.Fatalf("update object: %v", err)
}
got, err := repo.OwnedObject(ctx, o.ID, "")
if err != nil {
t.Fatalf("get object: %v", err)
}
if got.Address != newAddr {
t.Errorf("address = %q, want %q", got.Address, newAddr)
}
if !got.ProjectActive {
t.Error("project_active = false, want true")
}
// Составной FK: объект чужого проекта в заявку не проходит.
other := &models.Project{ClientID: owner.ID, Name: "БЦ Бета", IsActive: true}
if err := repo.CreateProject(ctx, other); err != nil {
t.Fatalf("create other project: %v", err)
}
expectInvalid(t, ctx, q, "object from another project", `
INSERT INTO tickets (client_id, project_id, object_id, address, category, description)
VALUES ($1, $2, $3, 'addr', 'other', 'desc')`,
owner.ID, other.ID, o.ID)
}
// TestProjectWorkers — мастера проекта: членство только по приглашению
// (invite → accept), дубликаты с точной причиной и отзыв.
func TestProjectWorkers(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
strangerWorker := createProfile(t, ctx, q, "worker")
p := &models.Project{ClientID: owner.ID, Name: "ЖК Восток", IsActive: true}
if err := repo.CreateProject(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
// До приглашения членства нет вообще — назначить такого мастера нельзя.
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "" {
t.Errorf("membership before invite: status=%q err=%v, want empty/nil", status, err)
}
// Приглашение: строка появляется сразу, но рабочей она ещё не.
expires, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID)
if err != nil {
t.Fatalf("invite: %v", err)
}
if !expires.After(time.Now()) {
t.Errorf("expires_at = %v, want in the future", expires)
}
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "invited" {
t.Errorf("membership after invite: status=%q err=%v, want invited/nil", status, err)
}
// Повторное приглашение по висящему — конфликт с точной причиной
// (под savepoint, чтобы не отравить транзакцию теста).
expectError(t, ctx, q, "duplicate invite",
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
ErrInvitationPending)
// Мастер принял — членство активное.
if err := repo.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
t.Fatalf("accept: %v", err)
}
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "active" {
t.Errorf("membership after accept: status=%q err=%v, want active/nil", status, err)
}
// Активного мастера заново не зовут и повторно не принимают.
expectError(t, ctx, q, "invite active member",
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
ErrMembershipActive)
expectError(t, ctx, q, "accept twice",
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
ErrInvitationNotPending)
// Чужой мастер здесь не состоит.
if status, err := repo.MembershipStatus(ctx, strangerWorker.ID, p.ID); err != nil || status != "" {
t.Errorf("foreign membership: status=%q err=%v, want empty/nil", status, err)
}
// Состав проекта: профиль плюс статус, без хеша пароля.
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list workers: %v", err)
}
if len(workers) != 1 || workers[0].ID != worker.ID || workers[0].Status != "active" {
t.Errorf("workers = %v (status %q), want only %s and active",
workerIDs(workers), workers[0].Status, worker.ID)
}
if total != 1 {
t.Errorf("workers total = %d, want 1", total)
}
if workers[0].PasswordHash != "" {
t.Error("password hash leaked into worker list")
}
// Отзыв без открытых заявок — чисто.
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); err != nil {
t.Fatalf("remove worker: %v", err)
}
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("second remove: err = %v, want ErrNotFound", err)
}
}
// TestInvitationDeclineAndExpiry — отказ, просрочка и повторное приглашение.
// Всё живёт на уровне SQL: обход хендлера статус не поменяет.
func TestInvitationDeclineAndExpiry(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
other := createProfile(t, ctx, q, "worker")
p := &models.Project{ClientID: owner.ID, Name: "ЖК Закат", IsActive: true}
if err := repo.CreateProject(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
// Приглашение видно мастеру сразу: проект, кто пригласил, срок.
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
t.Fatalf("invite: %v", err)
}
invites, total, err := repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list invitations: %v", err)
}
if len(invites) != 1 || total != 1 {
t.Fatalf("invitations = %d/%d, want 1/1", len(invites), total)
}
got := invites[0]
if got.ProjectID != p.ID || got.ProjectName != p.Name ||
got.InvitedBy != owner.ID || got.InvitedName != owner.Name {
t.Errorf("invitation = %+v, want project %d and inviter %s", got, p.ID, owner.ID)
}
if !got.ExpiresAt.After(time.Now()) || !got.InvitedAt.Before(got.ExpiresAt) {
t.Errorf("invitation times: invited=%v expires=%v, want a future deadline", got.InvitedAt, got.ExpiresAt)
}
// Мастер отклонил: строка помечена, ждать решения больше нечего.
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
t.Fatalf("decline: %v", err)
}
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "declined" {
t.Errorf("membership after decline: status=%q err=%v, want declined/nil", status, err)
}
expectError(t, ctx, q, "accept declined",
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
ErrInvitationNotPending)
// Отказавшему приглашения не видно.
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list after decline: %v", err)
}
if len(invites) != 0 || total != 0 {
t.Errorf("invitations after decline = %d/%d, want empty", len(invites), total)
}
// Повторное приглашение перезапускает ту же строку — не дубль.
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
t.Errorf("re-invite after decline: err = %v, want nil", err)
}
// Просрочка: принять нельзя (409 invitation expired), но строка
// остаётся «висячей», пока клиент не отзовёт её DELETE'ом.
if err := expireInvitations(ctx, q, p.ID, worker.ID); err != nil {
t.Fatalf("expire: %v", err)
}
expectError(t, ctx, q, "accept expired",
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
ErrInvitationExpired)
// Просроченное не отдаётся в списке: принимать уже нечего.
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
if err != nil {
t.Fatalf("list after expiry: %v", err)
}
if len(invites) != 0 || total != 0 {
t.Errorf("expired invitation listed: %d/%d, want empty", len(invites), total)
}
// Отказ по просрочке проходит — устаревшая строка и так мертва.
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
t.Errorf("decline expired: err = %v, want nil", err)
}
// Кому приглашения не было — отвечать нечего: 404.
if err := repo.AcceptInvitation(ctx, p.ID, other.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("accept without invitation: err = %v, want ErrNotFound", err)
}
}
// TestListWorkersEmptyFallback — пустое окно списка состава: total
// досчитывается отдельным COUNT, а не остаётся нулём.
func TestListWorkersEmptyFallback(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
p := &models.Project{ClientID: owner.ID, Name: "ЖК Поле", IsActive: true}
if err := repo.CreateProject(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
joinProject(t, ctx, repo, p.ID, worker.ID, owner.ID)
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit, Offset: 100})
if err != nil {
t.Fatalf("list beyond the end: %v", err)
}
if len(workers) != 0 || total != 1 {
t.Errorf("page beyond the end = %d items/%d total, want 0 items/1 total", len(workers), total)
}
}
// joinProject — мастер входит в проект: с 0005 членство даёт только
// приглашение плюс согласие самого мастера (invite → accept).
func joinProject(t *testing.T, ctx context.Context, repo *ProjectRepo, projectID int64, workerID, invitedBy string) {
t.Helper()
if _, err := repo.InviteWorker(ctx, projectID, workerID, invitedBy); err != nil {
t.Fatalf("invite worker: %v", err)
}
if err := repo.AcceptInvitation(ctx, projectID, workerID); err != nil {
t.Fatalf("accept invitation: %v", err)
}
}
// expireInvitations — убить срок действия приглашения (для теста просрочки).
func expireInvitations(ctx context.Context, q Querier, projectID int64, workerID string) error {
_, err := q.Exec(ctx,
`UPDATE project_workers SET expires_at = now() - interval '1 minute'
WHERE worker_id = $1 AND project_id = $2`,
workerID, projectID)
return err
}
// TestRemoveWorkerBlockedByTickets — мастера нельзя убрать из проекта,
// пока за ним не закрыты заявки: иначе работа осталась бы без исполнителя.
// После закрытия отзыв проходит, история при этом не теряется.
func TestRemoveWorkerBlockedByTickets(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
tickets := NewTicketRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
tk := &models.Ticket{
ClientID: owner.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "other",
Description: "test ticket",
Priority: "normal",
}
if err := tickets.Create(ctx, tk); err != nil {
t.Fatalf("create ticket: %v", err)
}
// Назначаем заявку мастеру: пока она не закрыта, убрать его нельзя —
// иначе работа осталась бы без исполнителя.
if err := tickets.Assign(ctx, tk.ID, worker.ID, worker.ID); err != nil {
t.Fatalf("assign: %v", err)
}
if err := repo.RemoveWorker(ctx, projectID, worker.ID); !errors.Is(err, ErrConflict) {
t.Errorf("remove with open ticket: err = %v, want ErrConflict", err)
}
// Закрываем заявку — отзыв разблокируется, заявка остаётся с мастером.
if err := tickets.Close(ctx, tk.ID, owner.ID, "передумал", []string{"new", "assigned", "done", "rejected"}); err != nil {
t.Fatalf("close: %v", err)
}
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
t.Errorf("remove after close: err = %v, want nil", err)
}
got, err := tickets.GetByID(ctx, tk.ID)
if err != nil {
t.Fatalf("get ticket: %v", err)
}
if got.WorkerID == nil || *got.WorkerID != worker.ID {
t.Errorf("worker_id = %v, want %s kept in history", got.WorkerID, worker.ID)
}
if got.ProjectID != projectID || got.ObjectID != objectID {
t.Errorf("project/object = %d/%d, want %d/%d", got.ProjectID, got.ObjectID, projectID, objectID)
}
// Неназначенная заявка (worker_id IS NULL) мастера не удерживает:
// исполнитель у неё и так не он. Членство набирается заново —
// invite → accept, строка новая (старую удалили).
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
free := &models.Ticket{
ClientID: owner.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "other",
Description: "free ticket",
Priority: "normal",
}
if err := tickets.Create(ctx, free); err != nil {
t.Fatalf("create free ticket: %v", err)
}
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
t.Errorf("remove with only unassigned ticket: err = %v, want nil", err)
}
}
// TestAssignRequiresProjectMembership — «чужого» мастера не назначить:
// проверка стоит внутри UPDATE, поэтому обход хендлера не поможет.
func TestAssignRequiresProjectMembership(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
tickets := NewTicketRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
tk := &models.Ticket{
ClientID: owner.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "other",
Description: "test ticket",
Priority: "normal",
}
if err := tickets.Create(ctx, tk); err != nil {
t.Fatalf("create ticket: %v", err)
}
// Мастер не состоит в проекте — назначение не применяется.
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
t.Errorf("assign foreign worker: err = %v, want ErrConflict", err)
}
got := mustGetTicket(t, ctx, tickets, tk.ID)
if got.WorkerID != nil || got.Status != "new" {
t.Errorf("ticket = %q/%v, want new/nil", got.Status, got.WorkerID)
}
// Приглашён, но не принял — всё равно не назначается: рубеж в самом
// UPDATE (status = 'active'), обход хендлера не поможет.
if _, err := repo.InviteWorker(ctx, projectID, worker.ID, owner.ID); err != nil {
t.Fatalf("invite: %v", err)
}
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
t.Errorf("assign invited worker: err = %v, want ErrConflict", err)
}
got = mustGetTicket(t, ctx, tickets, tk.ID)
if got.WorkerID != nil || got.Status != "new" {
t.Errorf("ticket after invited assign = %q/%v, want new/nil", got.Status, got.WorkerID)
}
// Принял приглашение — назначение проходит.
if err := repo.AcceptInvitation(ctx, projectID, worker.ID); err != nil {
t.Fatalf("accept: %v", err)
}
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
t.Fatalf("assign member: %v", err)
}
assigned := mustGetTicket(t, ctx, tickets, tk.ID)
if assigned.WorkerID == nil || *assigned.WorkerID != worker.ID {
t.Errorf("worker_id = %v, want %s", assigned.WorkerID, worker.ID)
}
}
// TestDeleteProjectCascade — удаление проекта: заявки, объекты, участия
// и журнал уходят каскадом (0004/0006/0008/0013), ключи фото возвращаются
// вызывающей стороне, повторное удаление — 404. Чужой проект не трогаем.
func TestDeleteProjectCascade(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewProjectRepo(q)
tickets := NewTicketRepo(q)
photos := NewPhotoRepo(q)
owner := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
tk := &models.Ticket{
ClientID: owner.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "other",
Description: "заявка проекта",
Priority: "normal",
}
if err := tickets.Create(ctx, tk); err != nil {
t.Fatalf("create ticket: %v", err)
}
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
t.Fatalf("assign: %v", err)
}
// Фотоотчёт открывается только после старта работы (статус in_progress).
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
t.Fatalf("start: %v", err)
}
meta := photoMeta(tk.ID, worker.ID, 1, 10)
if _, err := photos.AddPhoto(ctx, meta, "worker"); err != nil {
t.Fatalf("add photo: %v", err)
}
// Соседний проект того же клиента: после удаления должен остаться.
neighbor := &models.Project{ClientID: owner.ID, Name: "Сосед", IsActive: true}
if err := repo.CreateProject(ctx, neighbor); err != nil {
t.Fatalf("create neighbor: %v", err)
}
// Удаление: возвращаются ключи объектов фото (их чистит хендлер),
// все вложенные строки исчезают каскадом.
keys, err := repo.DeleteProject(ctx, projectID)
if err != nil {
t.Fatalf("delete project: %v", err)
}
if len(keys) != 1 || keys[0] != meta.ObjectKey {
t.Errorf("keys = %v, want [%s]", keys, meta.ObjectKey)
}
if _, err := repo.OwnedProject(ctx, projectID, ""); !errors.Is(err, ErrNotFound) {
t.Errorf("project after delete: err = %v, want ErrNotFound", err)
}
if _, err := repo.OwnedObject(ctx, objectID, ""); !errors.Is(err, ErrNotFound) {
t.Errorf("object after delete: err = %v, want ErrNotFound", err)
}
if _, err := tickets.GetByID(ctx, tk.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("ticket after delete: err = %v, want ErrNotFound", err)
}
if status, err := repo.MembershipStatus(ctx, worker.ID, projectID); err != nil || status != "" {
t.Errorf("membership after delete: status=%q err=%v, want empty/nil", status, err)
}
// Строки фото и событий журнала ушли каскадом вместе с заявкой.
var photosLeft, eventsLeft int
if err := q.QueryRow(ctx,
`SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, tk.ID).Scan(&photosLeft); err != nil {
t.Fatalf("count photos: %v", err)
}
if err := q.QueryRow(ctx,
`SELECT COUNT(*) FROM ticket_events WHERE ticket_id = $1`, tk.ID).Scan(&eventsLeft); err != nil {
t.Fatalf("count events: %v", err)
}
if photosLeft != 0 || eventsLeft != 0 {
t.Errorf("photo rows = %d, event rows = %d, want 0/0", photosLeft, eventsLeft)
}
// Повторное удаление — проект не существует (404).
if _, err := repo.DeleteProject(ctx, projectID); !errors.Is(err, ErrNotFound) {
t.Errorf("second delete: err = %v, want ErrNotFound", err)
}
// Чужой проект не пострадал.
if _, err := repo.OwnedProject(ctx, neighbor.ID, owner.ID); err != nil {
t.Errorf("neighbor project: err = %v, want nil", err)
}
}
func projectIDs(projects []models.Project) []int64 {
out := make([]int64, 0, len(projects))
for _, p := range projects {
out = append(out, p.ID)
}
return out
}
func workerIDs(workers []models.ProjectWorker) []string {
out := make([]string, 0, len(workers))
for _, w := range workers {
out = append(out, w.ID)
}
return out
}