588 lines
18 KiB
Go
588 lines
18 KiB
Go
package repository
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// ErrNotFound is returned when a row is absent (or not owned by the scoping device).
|
|
var ErrNotFound = errors.New("resource not found")
|
|
|
|
// ErrNameConflict is returned when a sibling with the same name already exists.
|
|
var ErrNameConflict = errors.New("name conflict")
|
|
|
|
// NewID returns an opaque lowercase 32-hex identifier (charset ^[0-9a-f]{32}$),
|
|
// mirroring the mobile's lower(hex(randomblob(16))). Never a UUID.
|
|
func NewID() string {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(fmt.Sprintf("crypto/rand failed: %v", err))
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pqErr *pq.Error
|
|
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
|
}
|
|
|
|
type FileRow struct {
|
|
ID string
|
|
Name string
|
|
Size int64
|
|
MimeType string
|
|
FolderID string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type FolderRow struct {
|
|
ID string
|
|
Name string
|
|
ParentID string
|
|
}
|
|
|
|
// Resources implements resource persistence, always scoped by owner device.
|
|
type Resources struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
// sortColumns maps contract `sort` values to safe SQL columns.
|
|
var sortColumns = map[string]string{
|
|
"size": "size_bytes",
|
|
"created_at": "created_at",
|
|
"updated_at": "updated_at",
|
|
"added_at": "created_at",
|
|
}
|
|
|
|
func sortClause(sort, order string) (string, string) {
|
|
column, ok := sortColumns[sort]
|
|
if !ok {
|
|
column = "created_at"
|
|
}
|
|
direction := "DESC"
|
|
if order == "asc" {
|
|
direction = "ASC"
|
|
}
|
|
return column, direction
|
|
}
|
|
|
|
// grantedCTE expands, for the user bound to $1, the set of accessible
|
|
// resource ids — owned rows (each its own seed) + active direct grants +
|
|
// every node reached by an inherit=true grant up its chain (passing through
|
|
// intermediate nodes, per docs/api-v1.md §6.2 rule 5). Only the visibility
|
|
// SET matters here (which resources the user can read, viewer+); the effective
|
|
// rank is validated by repository.Shares.EffectiveAccess. Walk bounded at
|
|
// depth < 100. Append a query that references
|
|
// `resource_id IN (SELECT id FROM granted)`.
|
|
const grantedCTE = `
|
|
seeded(id, type, rank, inherit, depth) AS (
|
|
SELECT r.resource_id, r.type, 4::int, FALSE, 0
|
|
FROM resources r
|
|
WHERE r.user_id = $1 AND r.deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT r.resource_id, r.type,
|
|
CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END,
|
|
sh.inherit, 0
|
|
FROM shares sh
|
|
JOIN resources r ON r.resource_id = sh.resource_id
|
|
WHERE sh.grantee_user_id = $1 AND sh.revoked_at IS NULL
|
|
AND (sh.expires_at IS NULL OR sh.expires_at > NOW())
|
|
AND r.deleted_at IS NULL
|
|
),
|
|
granted(id, rank, inherit, depth) AS (
|
|
SELECT id, rank, inherit, 0 FROM seeded
|
|
UNION ALL
|
|
SELECT r.resource_id, g.rank, TRUE, g.depth + 1
|
|
FROM resources r
|
|
JOIN granted g ON r.parent_id = g.id
|
|
WHERE g.inherit AND g.rank BETWEEN 1 AND 3
|
|
AND r.deleted_at IS NULL AND g.depth < 100
|
|
)`
|
|
|
|
func (r *Resources) folderExists(ownerID, folderID string) (bool, error) {
|
|
var exists int
|
|
err := r.DB.QueryRow(
|
|
`SELECT 1 FROM resources
|
|
WHERE resource_id = $1 AND user_id = $2 AND type = 'folder' AND deleted_at IS NULL`,
|
|
folderID, ownerID,
|
|
).Scan(&exists)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
func (r *Resources) insert(ownerID, id, name, parentResourceID, resourceType string, size int64, mimeType, extension *string) error {
|
|
if parentResourceID != "" {
|
|
ok, err := r.folderExists(ownerID, parentResourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("folder %s: %w", parentResourceID, ErrNotFound)
|
|
}
|
|
}
|
|
var parentID any
|
|
if parentResourceID != "" {
|
|
parentID = parentResourceID
|
|
}
|
|
_, err := r.DB.Exec(
|
|
`INSERT INTO resources (resource_id, type, name, parent_id, user_id, size_bytes, mime_type, extension)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
id, resourceType, name, parentID, ownerID, size, mimeType, extension,
|
|
)
|
|
if isUniqueViolation(err) {
|
|
return ErrNameConflict
|
|
}
|
|
return err
|
|
}
|
|
|
|
// InsertFile persists a file metadata row for the owner device. The parent
|
|
// folder (when non-empty) must belong to the same device.
|
|
func (r *Resources) InsertFile(ownerID, id, name, folderResourceID string, size int64, mimeType, extension *string) error {
|
|
return r.insert(ownerID, id, name, folderResourceID, "file", size, mimeType, extension)
|
|
}
|
|
|
|
// InsertFolder persists a folder metadata row.
|
|
func (r *Resources) InsertFolder(ownerID, id, name, parentResourceID string) error {
|
|
return r.insert(ownerID, id, name, parentResourceID, "folder", 0, nil, nil)
|
|
}
|
|
|
|
const fileColumns = `resource_id, name, size_bytes, COALESCE(mime_type, ''), COALESCE(parent_id, ''), created_at, updated_at`
|
|
|
|
func (r *Resources) scanFile(scan func(...any) error) (FileRow, error) {
|
|
var row FileRow
|
|
err := scan(&row.ID, &row.Name, &row.Size, &row.MimeType, &row.FolderID, &row.CreatedAt, &row.UpdatedAt)
|
|
return row, err
|
|
}
|
|
|
|
// ListFiles returns the owner device's files (within a folder, or at the
|
|
// root when folderResourceID is empty), plus the total count.
|
|
func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
|
|
column, direction := sortClause(sort, order)
|
|
where := `type = 'file' AND deleted_at IS NULL AND user_id = $1 AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
|
|
|
|
var total int
|
|
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderResourceID).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := r.DB.Query(
|
|
fmt.Sprintf(`SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`,
|
|
fileColumns, where, column, direction),
|
|
ownerID, folderResourceID, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
files := make([]FileRow, 0)
|
|
for rows.Next() {
|
|
row, err := r.scanFile(rows.Scan)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
files = append(files, row)
|
|
}
|
|
return files, total, rows.Err()
|
|
}
|
|
|
|
// ListFilesVisible returns the user's accessible files (owned or shared,
|
|
// viewer+) within a folder (or at the root when folderResourceID is empty).
|
|
func (r *Resources) ListFilesVisible(userID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
|
|
column, direction := sortClause(sort, order)
|
|
where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted)
|
|
AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
|
|
|
|
var total int
|
|
if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, folderResourceID).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := r.DB.Query(
|
|
fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`,
|
|
grantedCTE, fileColumns, where, column, direction),
|
|
userID, folderResourceID, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
files := make([]FileRow, 0)
|
|
for rows.Next() {
|
|
row, err := r.scanFile(rows.Scan)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
files = append(files, row)
|
|
}
|
|
return files, total, rows.Err()
|
|
}
|
|
|
|
// GetFile returns a file owned by the device (no-rows → ErrNotFound).
|
|
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
|
row := r.DB.QueryRow(
|
|
`SELECT `+fileColumns+` FROM resources
|
|
WHERE type = 'file' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
|
ownerID, resourceID,
|
|
)
|
|
file, err := r.scanFile(row.Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return FileRow{}, ErrNotFound
|
|
}
|
|
return file, err
|
|
}
|
|
|
|
// GetFileVisible returns a file the user can access (owned or shared, viewer+).
|
|
func (r *Resources) GetFileVisible(userID, resourceID string) (FileRow, error) {
|
|
row := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+`
|
|
SELECT `+fileColumns+` FROM resources
|
|
WHERE type = 'file' AND deleted_at IS NULL AND resource_id = $2
|
|
AND resource_id IN (SELECT id FROM granted)`,
|
|
userID, resourceID,
|
|
)
|
|
file, err := r.scanFile(row.Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return FileRow{}, ErrNotFound
|
|
}
|
|
return file, err
|
|
}
|
|
|
|
// GetResourceOwner returns the owning user_id of any resource (file or folder).
|
|
// No-rows → ErrNotFound.
|
|
func (r *Resources) GetResourceOwner(resourceID string) (string, error) {
|
|
var ownerID string
|
|
err := r.DB.QueryRow(
|
|
`SELECT user_id FROM resources
|
|
WHERE resource_id = $1 AND deleted_at IS NULL`,
|
|
resourceID,
|
|
).Scan(&ownerID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return ownerID, err
|
|
}
|
|
|
|
// DeleteFile soft-deletes the file (deleted_at), returning its id.
|
|
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
|
result, err := r.DB.Exec(
|
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE type = 'file' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
|
ownerID, resourceID,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if affected == 0 {
|
|
return "", ErrNotFound
|
|
}
|
|
return resourceID, nil
|
|
}
|
|
|
|
// SearchFiles returns the owner device's files whose name matches q
|
|
// (case-insensitive substring, wildcards escaped), plus the total count.
|
|
func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow, int, error) {
|
|
pattern := `%` + escapeLike(q) + `%`
|
|
where := `type = 'file' AND deleted_at IS NULL AND user_id = $1 AND name ILIKE $2 ESCAPE '\'`
|
|
|
|
var total int
|
|
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, pattern).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := r.DB.Query(
|
|
fmt.Sprintf(`SELECT %s FROM resources WHERE %s ORDER BY name ASC LIMIT $3 OFFSET $4`,
|
|
fileColumns, where),
|
|
ownerID, pattern, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
files := make([]FileRow, 0)
|
|
for rows.Next() {
|
|
row, err := r.scanFile(rows.Scan)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
files = append(files, row)
|
|
}
|
|
return files, total, rows.Err()
|
|
}
|
|
|
|
// SearchFilesVisible returns the user's accessible files (owned or shared,
|
|
// viewer+) whose name matches q (case-insensitive substring).
|
|
func (r *Resources) SearchFilesVisible(userID, q string, limit, offset int) ([]FileRow, int, error) {
|
|
pattern := `%` + escapeLike(q) + `%`
|
|
where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted) AND name ILIKE $2 ESCAPE '\'`
|
|
|
|
var total int
|
|
if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, pattern).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := r.DB.Query(
|
|
fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY name ASC LIMIT $3 OFFSET $4`,
|
|
grantedCTE, fileColumns, where),
|
|
userID, pattern, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
files := make([]FileRow, 0)
|
|
for rows.Next() {
|
|
row, err := r.scanFile(rows.Scan)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
files = append(files, row)
|
|
}
|
|
return files, total, rows.Err()
|
|
}
|
|
|
|
func escapeLike(q string) string {
|
|
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|
return replacer.Replace(q)
|
|
}
|
|
|
|
// GetFolder returns a folder row owned by the device (no-rows → ErrNotFound).
|
|
func (r *Resources) GetFolder(ownerID, resourceID string) (FolderRow, error) {
|
|
var row FolderRow
|
|
err := r.DB.QueryRow(
|
|
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
|
WHERE type = 'folder' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
|
ownerID, resourceID,
|
|
).Scan(&row.ID, &row.Name, &row.ParentID)
|
|
if err == sql.ErrNoRows {
|
|
return FolderRow{}, ErrNotFound
|
|
}
|
|
return row, err
|
|
}
|
|
|
|
// MoveResource sets parent_id (root when parentResourceID empty). The target
|
|
// folder must exist and belong to the owner. Absent source is a no-op.
|
|
func (r *Resources) MoveResource(ownerID, resourceID, parentResourceID string) error {
|
|
if parentResourceID != "" {
|
|
if _, err := r.GetFolder(ownerID, parentResourceID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var parentID any
|
|
if parentResourceID != "" {
|
|
parentID = parentResourceID
|
|
}
|
|
result, err := r.DB.Exec(
|
|
`UPDATE resources SET parent_id = $3, updated_at = NOW()
|
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
|
resourceID, ownerID, parentID,
|
|
)
|
|
if err != nil && isUniqueViolation(err) {
|
|
return ErrNameConflict
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateName renames a resource. Absent source is a no-op.
|
|
func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
|
|
result, err := r.DB.Exec(
|
|
`UPDATE resources SET name = $3, updated_at = NOW()
|
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
|
resourceID, ownerID, name,
|
|
)
|
|
if err != nil && isUniqueViolation(err) {
|
|
return ErrNameConflict
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateNameByID renames a resource without owner scoping — the caller
|
|
// (service) has already verified the user's effective access (editor+).
|
|
func (r *Resources) UpdateNameByID(resourceID, name string) error {
|
|
result, err := r.DB.Exec(
|
|
`UPDATE resources SET name = $2, updated_at = NOW()
|
|
WHERE resource_id = $1 AND deleted_at IS NULL`,
|
|
resourceID, name,
|
|
)
|
|
if err != nil && isUniqueViolation(err) {
|
|
return ErrNameConflict
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdatePhysical refreshes an existing file row's physical metadata
|
|
// (size_bytes / mime_type / extension) after a targeted re-upload — used so a
|
|
// resource created metadata-only (outbox create_resource) receives its bytes
|
|
// for server-side OCR. Absent / not owned → ErrNotFound. Never renames or moves.
|
|
func (r *Resources) UpdatePhysical(ownerID, resourceID string, size int64, mimeType, extension *string) error {
|
|
result, err := r.DB.Exec(
|
|
`UPDATE resources SET size_bytes = $3, mime_type = $4, extension = $5, updated_at = NOW()
|
|
WHERE resource_id = $1 AND user_id = $2 AND type = 'file' AND deleted_at IS NULL`,
|
|
resourceID, ownerID, size, mimeType, extension,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
|
|
// terminal state for the outbox).
|
|
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
|
_, err := r.DB.Exec(
|
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
|
resourceID, ownerID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ExistsOwner reports whether a non-deleted resource belongs to the device.
|
|
func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) {
|
|
var exists int
|
|
err := r.DB.QueryRow(
|
|
`SELECT 1 FROM resources WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
|
resourceID, ownerID,
|
|
).Scan(&exists)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
// TouchResource bumps updated_at on a non-deleted resource so that the
|
|
// grantee's delta snapshot picks it up after a share is created.
|
|
func (r *Resources) TouchResource(resourceID string) error {
|
|
_, err := r.DB.Exec(
|
|
`UPDATE resources SET updated_at = NOW() WHERE resource_id = $1 AND deleted_at IS NULL`,
|
|
resourceID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// OwnedRow is a snapshot row: resource identity + freshness.
|
|
type OwnedRow struct {
|
|
ID string
|
|
Type string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ListOwned returns the device's non-deleted resources whose updated_at
|
|
// (in epoch ms) is strictly greater than afterMs (0 = all).
|
|
func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) {
|
|
rows, err := r.DB.Query(
|
|
`SELECT resource_id, type, updated_at FROM resources
|
|
WHERE user_id = $1 AND deleted_at IS NULL
|
|
AND (EXTRACT(EPOCH FROM updated_at) * 1000)::bigint > $2
|
|
ORDER BY updated_at DESC
|
|
LIMIT 10000`,
|
|
ownerID, afterMs,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]OwnedRow, 0)
|
|
for rows.Next() {
|
|
var row OwnedRow
|
|
if err := rows.Scan(&row.ID, &row.Type, &row.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListRootFolders returns the owner device's top-level folders (parent_id NULL).
|
|
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
|
|
rows, err := r.DB.Query(
|
|
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
|
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL AND user_id = $1
|
|
ORDER BY name ASC`,
|
|
ownerID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
folders := make([]FolderRow, 0)
|
|
for rows.Next() {
|
|
var folder FolderRow
|
|
if err := rows.Scan(&folder.ID, &folder.Name, &folder.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
folders = append(folders, folder)
|
|
}
|
|
return folders, rows.Err()
|
|
}
|
|
|
|
// ListRootFoldersVisible returns the user's accessible top-level folders —
|
|
// owned (rank 4) or shared (viewer+) at the root.
|
|
func (r *Resources) ListRootFoldersVisible(userID string) ([]FolderRow, error) {
|
|
rows, err := r.DB.Query(`WITH RECURSIVE `+grantedCTE+`
|
|
SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
|
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL
|
|
AND resource_id IN (SELECT id FROM granted)
|
|
ORDER BY name ASC`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
folders := make([]FolderRow, 0)
|
|
for rows.Next() {
|
|
var folder FolderRow
|
|
if err := rows.Scan(&folder.ID, &folder.Name, &folder.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
folders = append(folders, folder)
|
|
}
|
|
return folders, rows.Err()
|
|
}
|