12 Commits

Author SHA1 Message Date
e78098ad45 feat: update gitignore for attachment system
- Add .vscode/ to ignore IDE-specific files
- Add server to ignore build artifacts
- Add attachments/ to ignore uploaded attachment files
- Maintain clean repository without development artifacts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:06:29 +12:00
7c43726abf fix: correct WebSocket message logging format
- Change format specifier from %s to %+v for struct logging
- Resolve compilation error in WebSocket message handling
- Maintain proper logging functionality for debugging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:05:37 +12:00
b7ac4b0152 fix: add missing mock expectations in account tests
- Add GetSplitCountByAccountId mock expectations for CreateAccount tests
- Add GetSplitCountByAccountId mock expectations for UpdateAccount tests
- Resolve "unexpected method call" errors in account test suite
- Maintain existing test logic while fixing mock setup

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:05:21 +12:00
1b115fe0ff feat: add attachment methods to mock datastore
- Add InsertAttachment mock method for testing
- Add GetAttachment and GetAttachmentsByTransaction mock methods
- Add DeleteAttachment mock method for testing
- Maintain consistency with existing mock patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:05:05 +12:00
a87df47231 feat: register attachment API routes
- Add 5 RESTful endpoints for transaction attachment management
- Include proper authentication middleware for all attachment operations
- Follow existing URL pattern: /orgs/:orgId/transactions/:transactionId/attachments
- Support nested resource access with proper authorization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:04:50 +12:00
8b0a72c81f feat: implement attachment REST API endpoints
- Add POST /attachments for secure multi-file upload with validation
- Add GET /attachments for listing transaction attachments
- Add GET /attachments/:id for attachment metadata retrieval
- Add GET /attachments/:id/download for secure file download
- Add DELETE /attachments/:id for soft deletion
- Include comprehensive security validation: file type, size, content detection
- Implement proper error handling and cleanup on failures

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:04:32 +12:00
f64f83e66f feat: add attachment support to GORM model
- Implement AttachmentInterface methods in GormModel
- Add GetTransaction method for interface compliance
- Include placeholder implementation for future GORM repository development
- Maintain backward compatibility with existing GORM usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:04:15 +12:00
f5f0853040 feat: implement attachment business logic layer
- Add AttachmentInterface to main model interface
- Implement attachment CRUD operations with permission checking
- Add GetTransaction method for secure attachment access validation
- Add accountsContainReadAccess for permission verification
- Ensure users can only access attachments for authorized transactions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:04:01 +12:00
04653f2f02 feat: implement attachment database layer
- Add AttachmentInterface to main Datastore interface
- Implement CRUD operations for attachments following existing patterns
- Add proper SQL marshalling/unmarshalling with HEX/UNHEX for binary IDs
- Include soft deletion and proper indexing support

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:03:44 +12:00
3b89d8137e feat: add UUID utility functions for attachment system
- Add NewUUID() function for generating unique attachment identifiers
- Add IsValidUUID() function for validating UUID format in API requests
- Support 32-character hex string validation for secure file handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:03:30 +12:00
d10686e70f feat: add attachment model type definitions
- Add core attachment type with metadata fields for transaction files
- Add GORM model for attachment with proper relationships
- Include file information, upload timestamps, and soft deletion support

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:03:13 +12:00
c335c834ba feat: add database schema for transaction attachments
- Add attachment table with fields for file metadata and relationships
- Include indexes for optimal query performance on transactionId, orgId, userId, and uploaded fields
- Support for file storage with path tracking and soft deletion

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 11:02:58 +12:00
18 changed files with 836 additions and 3 deletions

3
.gitignore vendored
View File

@@ -97,3 +97,6 @@ config.json
*.csr
*.sublime-project
*.sublime-workspace
.vscode/
server
attachments/

313
core/api/attachment.go Normal file
View File

@@ -0,0 +1,313 @@
package api
import (
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/ant0ine/go-json-rest/rest"
"github.com/openaccounting/oa-server/core/model"
"github.com/openaccounting/oa-server/core/model/types"
"github.com/openaccounting/oa-server/core/util"
)
const (
MaxFileSize = 10 * 1024 * 1024 // 10MB
MaxFilesPerTx = 10
AttachmentDir = "attachments"
)
var AllowedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"application/pdf": true,
"text/plain": true,
"text/csv": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, // .xlsx
"application/vnd.ms-excel": true, // .xls
}
func PostAttachment(w rest.ResponseWriter, r *rest.Request) {
orgId := r.PathParam("orgId")
transactionId := r.PathParam("transactionId")
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) {
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
return
}
user := r.Env["USER"].(*types.User)
// Parse multipart form
err := r.ParseMultipartForm(MaxFileSize)
if err != nil {
rest.Error(w, "Failed to parse multipart form", http.StatusBadRequest)
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
rest.Error(w, "No files provided", http.StatusBadRequest)
return
}
if len(files) > MaxFilesPerTx {
rest.Error(w, fmt.Sprintf("Too many files. Maximum %d files allowed", MaxFilesPerTx), http.StatusBadRequest)
return
}
// Verify transaction exists and user has permission
tx, err := model.Instance.GetTransaction(transactionId, orgId, user.Id)
if err != nil {
rest.Error(w, "Transaction not found or access denied", http.StatusNotFound)
return
}
if tx == nil {
rest.Error(w, "Transaction not found", http.StatusNotFound)
return
}
var attachments []*types.Attachment
var description string
if desc := r.FormValue("description"); desc != "" {
description = desc
}
for _, fileHeader := range files {
attachment, err := processFileUpload(fileHeader, transactionId, orgId, user.Id, description)
if err != nil {
// Clean up any successfully uploaded files
for _, att := range attachments {
os.Remove(att.FilePath)
}
rest.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Save attachment to database
createdAttachment, err := model.Instance.CreateAttachment(attachment)
if err != nil {
// Clean up file and any previously uploaded files
os.Remove(attachment.FilePath)
for _, att := range attachments {
os.Remove(att.FilePath)
}
rest.Error(w, "Failed to save attachment", http.StatusInternalServerError)
return
}
attachments = append(attachments, createdAttachment)
}
w.WriteJson(map[string]interface{}{
"attachments": attachments,
"count": len(attachments),
})
}
func GetAttachments(w rest.ResponseWriter, r *rest.Request) {
orgId := r.PathParam("orgId")
transactionId := r.PathParam("transactionId")
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) {
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
return
}
user := r.Env["USER"].(*types.User)
attachments, err := model.Instance.GetAttachmentsByTransaction(transactionId, orgId, user.Id)
if err != nil {
rest.Error(w, "Failed to retrieve attachments", http.StatusInternalServerError)
return
}
w.WriteJson(attachments)
}
func GetAttachment(w rest.ResponseWriter, r *rest.Request) {
orgId := r.PathParam("orgId")
transactionId := r.PathParam("transactionId")
attachmentId := r.PathParam("attachmentId")
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
return
}
user := r.Env["USER"].(*types.User)
attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id)
if err != nil {
rest.Error(w, "Attachment not found or access denied", http.StatusNotFound)
return
}
w.WriteJson(attachment)
}
func DownloadAttachment(w rest.ResponseWriter, r *rest.Request) {
orgId := r.PathParam("orgId")
transactionId := r.PathParam("transactionId")
attachmentId := r.PathParam("attachmentId")
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
return
}
user := r.Env["USER"].(*types.User)
attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id)
if err != nil {
rest.Error(w, "Attachment not found or access denied", http.StatusNotFound)
return
}
// Check if file exists
if _, err := os.Stat(attachment.FilePath); os.IsNotExist(err) {
rest.Error(w, "File not found on disk", http.StatusNotFound)
return
}
// Set headers for file download
w.Header().Set("Content-Type", attachment.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", attachment.OriginalName))
// Open and serve file
file, err := os.Open(attachment.FilePath)
if err != nil {
rest.Error(w, "Failed to open file", http.StatusInternalServerError)
return
}
defer file.Close()
io.Copy(w.(http.ResponseWriter), file)
}
func DeleteAttachment(w rest.ResponseWriter, r *rest.Request) {
orgId := r.PathParam("orgId")
transactionId := r.PathParam("transactionId")
attachmentId := r.PathParam("attachmentId")
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
return
}
user := r.Env["USER"].(*types.User)
err := model.Instance.DeleteAttachment(attachmentId, transactionId, orgId, user.Id)
if err != nil {
rest.Error(w, "Failed to delete attachment or access denied", http.StatusInternalServerError)
return
}
w.WriteJson(map[string]string{"status": "deleted"})
}
func processFileUpload(fileHeader *multipart.FileHeader, transactionId, orgId, userId, description string) (*types.Attachment, error) {
// Validate file size
if fileHeader.Size > MaxFileSize {
return nil, fmt.Errorf("file too large. Maximum size is %d bytes", MaxFileSize)
}
// Open uploaded file
file, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %v", err)
}
defer file.Close()
// Validate file type from header
contentType := fileHeader.Header.Get("Content-Type")
if !AllowedMimeTypes[contentType] {
return nil, fmt.Errorf("file type %s not allowed", contentType)
}
// Validate file type by detecting content (more secure)
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil {
return nil, fmt.Errorf("failed to read file for content detection: %v", err)
}
// Reset file pointer to beginning
if _, err := file.Seek(0, 0); err != nil {
return nil, fmt.Errorf("failed to reset file pointer: %v", err)
}
detectedType := http.DetectContentType(buffer[:n])
if !AllowedMimeTypes[detectedType] {
return nil, fmt.Errorf("detected file type %s not allowed (header claimed %s)", detectedType, contentType)
}
// Generate unique filename
attachmentId := util.NewUUID()
ext := filepath.Ext(fileHeader.Filename)
fileName := attachmentId + ext
// Create attachments directory if it doesn't exist
uploadDir := filepath.Join(AttachmentDir, orgId, transactionId)
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create upload directory: %v", err)
}
// Create file path
filePath := filepath.Join(uploadDir, fileName)
// Create destination file
dst, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to create destination file: %v", err)
}
defer dst.Close()
// Copy file contents
if _, err := io.Copy(dst, file); err != nil {
return nil, fmt.Errorf("failed to save file: %v", err)
}
// Create attachment object
attachment := &types.Attachment{
Id: attachmentId,
TransactionId: transactionId,
OrgId: orgId,
UserId: userId,
FileName: fileName,
OriginalName: fileHeader.Filename,
ContentType: contentType,
FileSize: fileHeader.Size,
FilePath: filePath,
Description: description,
Uploaded: time.Now(),
Deleted: false,
}
return attachment, nil
}
func sanitizeFilename(filename string) string {
// Remove potentially dangerous characters
filename = strings.ReplaceAll(filename, "..", "")
filename = strings.ReplaceAll(filename, "/", "")
filename = strings.ReplaceAll(filename, "\\", "")
filename = strings.ReplaceAll(filename, "\x00", "") // null bytes
filename = strings.ReplaceAll(filename, "\r", "") // carriage return
filename = strings.ReplaceAll(filename, "\n", "") // newline
// Limit filename length
if len(filename) > 255 {
ext := filepath.Ext(filename)
base := filename[:255-len(ext)]
filename = base + ext
}
return filename
}

View File

@@ -31,6 +31,11 @@ func GetRouter(auth *AuthMiddleware, prefix string) (rest.App, error) {
rest.Post(prefix+"/orgs/:orgId/transactions", auth.RequireAuth(PostTransaction)),
rest.Put(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(PutTransaction)),
rest.Delete(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(DeleteTransaction)),
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments", auth.RequireAuth(GetAttachments)),
rest.Post(prefix+"/orgs/:orgId/transactions/:transactionId/attachments", auth.RequireAuth(PostAttachment)),
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId", auth.RequireAuth(GetAttachment)),
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId/download", auth.RequireAuth(DownloadAttachment)),
rest.Delete(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId", auth.RequireAuth(DeleteAttachment)),
rest.Get(prefix+"/orgs/:orgId/prices", auth.RequireAuth(GetPrices)),
rest.Post(prefix+"/orgs/:orgId/prices", auth.RequireAuth(PostPrice)),
rest.Delete(prefix+"/orgs/:orgId/prices/:priceId", auth.RequireAuth(DeletePrice)),

View File

@@ -993,3 +993,74 @@ func (_m *Datastore) VerifyUser(_a0 string) error {
return r0
}
// Attachment interface mock methods
func (_m *Datastore) InsertAttachment(_a0 *types.Attachment) error {
ret := _m.Called(_a0)
var r0 error
if rf, ok := ret.Get(0).(func(*types.Attachment) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
func (_m *Datastore) GetAttachment(_a0 string, _a1 string, _a2 string) (*types.Attachment, error) {
ret := _m.Called(_a0, _a1, _a2)
var r0 *types.Attachment
if rf, ok := ret.Get(0).(func(string, string, string) *types.Attachment); ok {
r0 = rf(_a0, _a1, _a2)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*types.Attachment)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
r1 = rf(_a0, _a1, _a2)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
func (_m *Datastore) GetAttachmentsByTransaction(_a0 string, _a1 string) ([]*types.Attachment, error) {
ret := _m.Called(_a0, _a1)
var r0 []*types.Attachment
if rf, ok := ret.Get(0).(func(string, string) []*types.Attachment); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*types.Attachment)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
func (_m *Datastore) DeleteAttachment(_a0 string, _a1 string, _a2 string) error {
ret := _m.Called(_a0, _a1, _a2)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, _a1, _a2)
} else {
r0 = ret.Error(0)
}
return r0
}

View File

@@ -408,6 +408,15 @@ func (model *Model) accountsContainWriteAccess(accounts []*types.Account, accoun
return false
}
func (model *Model) accountsContainReadAccess(accounts []*types.Account, accountId string) bool {
for _, account := range accounts {
if account.Id == accountId {
return true
}
}
return false
}
func (model *Model) getAccountFromList(accounts []*types.Account, accountId string) *types.Account {
for _, account := range accounts {
if account.Id == accountId {

View File

@@ -162,6 +162,10 @@ func TestCreateAccount(t *testing.T) {
td := &TdAccount{}
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
// Mock GetSplitCountByAccountId for parent account check
if test.account.Parent != "" {
td.On("GetSplitCountByAccountId", test.account.Parent).Return(int64(0), nil)
}
model := NewModel(td, nil, types.Config{})
@@ -206,6 +210,10 @@ func TestUpdateAccount(t *testing.T) {
td := &TdAccount{}
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
// Mock GetSplitCountByAccountId for parent account check
if test.account.Parent != "" {
td.On("GetSplitCountByAccountId", test.account.Parent).Return(int64(0), nil)
}
model := NewModel(td, nil, types.Config{})

163
core/model/attachment.go Normal file
View File

@@ -0,0 +1,163 @@
package model
import (
"errors"
"time"
"github.com/openaccounting/oa-server/core/model/types"
)
type AttachmentInterface interface {
CreateAttachment(*types.Attachment) (*types.Attachment, error)
GetAttachmentsByTransaction(string, string, string) ([]*types.Attachment, error)
GetAttachment(string, string, string, string) (*types.Attachment, error)
DeleteAttachment(string, string, string, string) error
}
func (model *Model) CreateAttachment(attachment *types.Attachment) (*types.Attachment, error) {
if attachment.Id == "" {
return nil, errors.New("attachment ID required")
}
if attachment.TransactionId == "" {
return nil, errors.New("transaction ID required")
}
if attachment.OrgId == "" {
return nil, errors.New("organization ID required")
}
if attachment.UserId == "" {
return nil, errors.New("user ID required")
}
if attachment.FileName == "" {
return nil, errors.New("file name required")
}
if attachment.FilePath == "" {
return nil, errors.New("file path required")
}
// Set upload timestamp
attachment.Uploaded = time.Now()
attachment.Deleted = false
// Save to database
err := model.db.InsertAttachment(attachment)
if err != nil {
return nil, err
}
return attachment, nil
}
func (model *Model) GetAttachmentsByTransaction(transactionId, orgId, userId string) ([]*types.Attachment, error) {
if transactionId == "" {
return nil, errors.New("transaction ID required")
}
if orgId == "" {
return nil, errors.New("organization ID required")
}
if userId == "" {
return nil, errors.New("user ID required")
}
// First verify the user has access to the transaction
tx, err := model.GetTransaction(transactionId, orgId, userId)
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction not found or access denied")
}
// Get attachments for the transaction
attachments, err := model.db.GetAttachmentsByTransaction(transactionId, orgId)
if err != nil {
return nil, err
}
return attachments, nil
}
func (model *Model) GetAttachment(attachmentId, transactionId, orgId, userId string) (*types.Attachment, error) {
if attachmentId == "" {
return nil, errors.New("attachment ID required")
}
if transactionId == "" {
return nil, errors.New("transaction ID required")
}
if orgId == "" {
return nil, errors.New("organization ID required")
}
if userId == "" {
return nil, errors.New("user ID required")
}
// First verify the user has access to the transaction
tx, err := model.GetTransaction(transactionId, orgId, userId)
if err != nil {
return nil, err
}
if tx == nil {
return nil, errors.New("transaction not found or access denied")
}
// Get the attachment
attachment, err := model.db.GetAttachment(attachmentId, transactionId, orgId)
if err != nil {
return nil, err
}
return attachment, nil
}
func (model *Model) DeleteAttachment(attachmentId, transactionId, orgId, userId string) error {
if attachmentId == "" {
return errors.New("attachment ID required")
}
if transactionId == "" {
return errors.New("transaction ID required")
}
if orgId == "" {
return errors.New("organization ID required")
}
if userId == "" {
return errors.New("user ID required")
}
// First verify the user has access to the transaction
tx, err := model.GetTransaction(transactionId, orgId, userId)
if err != nil {
return err
}
if tx == nil {
return errors.New("transaction not found or access denied")
}
// Verify the attachment exists and belongs to the transaction
attachment, err := model.db.GetAttachment(attachmentId, transactionId, orgId)
if err != nil {
return err
}
if attachment == nil {
return errors.New("attachment not found")
}
// Soft delete the attachment
err = model.db.DeleteAttachment(attachmentId, transactionId, orgId)
if err != nil {
return err
}
return nil
}

126
core/model/db/attachment.go Normal file
View File

@@ -0,0 +1,126 @@
package db
import (
"database/sql"
"github.com/openaccounting/oa-server/core/model/types"
"github.com/openaccounting/oa-server/core/util"
)
const attachmentFields = "LOWER(HEX(id)),LOWER(HEX(transactionId)),LOWER(HEX(orgId)),LOWER(HEX(userId)),fileName,originalName,contentType,fileSize,filePath,description,uploaded,deleted"
type AttachmentInterface interface {
InsertAttachment(*types.Attachment) error
GetAttachment(string, string, string) (*types.Attachment, error)
GetAttachmentsByTransaction(string, string) ([]*types.Attachment, error)
DeleteAttachment(string, string, string) error
}
func (db *DB) InsertAttachment(attachment *types.Attachment) error {
query := "INSERT INTO attachment(id,transactionId,orgId,userId,fileName,originalName,contentType,fileSize,filePath,description,uploaded,deleted) VALUES(UNHEX(?),UNHEX(?),UNHEX(?),UNHEX(?),?,?,?,?,?,?,?,?)"
_, err := db.Exec(
query,
attachment.Id,
attachment.TransactionId,
attachment.OrgId,
attachment.UserId,
attachment.FileName,
attachment.OriginalName,
attachment.ContentType,
attachment.FileSize,
attachment.FilePath,
attachment.Description,
util.TimeToMs(attachment.Uploaded),
attachment.Deleted,
)
return err
}
func (db *DB) GetAttachment(attachmentId, transactionId, orgId string) (*types.Attachment, error) {
query := "SELECT " + attachmentFields + " FROM attachment WHERE id = UNHEX(?) AND transactionId = UNHEX(?) AND orgId = UNHEX(?) AND deleted = false"
row := db.QueryRow(query, attachmentId, transactionId, orgId)
return db.unmarshalAttachment(row)
}
func (db *DB) GetAttachmentsByTransaction(transactionId, orgId string) ([]*types.Attachment, error) {
query := "SELECT " + attachmentFields + " FROM attachment WHERE transactionId = UNHEX(?) AND orgId = UNHEX(?) AND deleted = false ORDER BY uploaded DESC"
rows, err := db.Query(query, transactionId, orgId)
if err != nil {
return nil, err
}
return db.unmarshalAttachments(rows)
}
func (db *DB) DeleteAttachment(attachmentId, transactionId, orgId string) error {
query := "UPDATE attachment SET deleted = true WHERE id = UNHEX(?) AND transactionId = UNHEX(?) AND orgId = UNHEX(?)"
_, err := db.Exec(query, attachmentId, transactionId, orgId)
return err
}
func (db *DB) unmarshalAttachment(row *sql.Row) (*types.Attachment, error) {
attachment := &types.Attachment{}
var uploaded int64
err := row.Scan(
&attachment.Id,
&attachment.TransactionId,
&attachment.OrgId,
&attachment.UserId,
&attachment.FileName,
&attachment.OriginalName,
&attachment.ContentType,
&attachment.FileSize,
&attachment.FilePath,
&attachment.Description,
&uploaded,
&attachment.Deleted,
)
if err != nil {
return nil, err
}
attachment.Uploaded = util.MsToTime(uploaded)
return attachment, nil
}
func (db *DB) unmarshalAttachments(rows *sql.Rows) ([]*types.Attachment, error) {
defer rows.Close()
attachments := []*types.Attachment{}
for rows.Next() {
attachment := &types.Attachment{}
var uploaded int64
err := rows.Scan(
&attachment.Id,
&attachment.TransactionId,
&attachment.OrgId,
&attachment.UserId,
&attachment.FileName,
&attachment.OriginalName,
&attachment.ContentType,
&attachment.FileSize,
&attachment.FilePath,
&attachment.Description,
&uploaded,
&attachment.Deleted,
)
if err != nil {
return nil, err
}
attachment.Uploaded = util.MsToTime(uploaded)
attachments = append(attachments, attachment)
}
return attachments, nil
}

View File

@@ -15,6 +15,7 @@ type Datastore interface {
OrgInterface
AccountInterface
TransactionInterface
AttachmentInterface
PriceInterface
SessionInterface
ApiKeyInterface

View File

@@ -247,6 +247,38 @@ func (m *GormModel) InsertTransaction(transaction *types.Transaction) error {
return m.repository.InsertTransaction(transaction)
}
func (m *GormModel) GetTransaction(transactionId, orgId, userId string) (*types.Transaction, error) {
// For now, delegate to repository - in a full implementation, this would include permission checking
return m.repository.GetTransactionById(transactionId)
}
// AttachmentInterface implementation
func (m *GormModel) CreateAttachment(attachment *types.Attachment) (*types.Attachment, error) {
if attachment.Id == "" {
return nil, errors.New("attachment ID required")
}
// Set upload timestamp
attachment.Uploaded = time.Now()
attachment.Deleted = false
// For GORM implementation, we'd need to implement repository methods
// For now, return an error indicating not implemented
return nil, errors.New("attachment operations not yet implemented for GORM model")
}
func (m *GormModel) GetAttachmentsByTransaction(transactionId, orgId, userId string) ([]*types.Attachment, error) {
return nil, errors.New("attachment operations not yet implemented for GORM model")
}
func (m *GormModel) GetAttachment(attachmentId, transactionId, orgId, userId string) (*types.Attachment, error) {
return nil, errors.New("attachment operations not yet implemented for GORM model")
}
func (m *GormModel) DeleteAttachment(attachmentId, transactionId, orgId, userId string) error {
return errors.New("attachment operations not yet implemented for GORM model")
}
func (m *GormModel) GetTransactionById(id string) (*types.Transaction, error) {
return m.repository.GetTransactionById(id)
}

View File

@@ -20,11 +20,13 @@ type Interface interface {
OrgInterface
AccountInterface
TransactionInterface
AttachmentInterface
PriceInterface
SessionInterface
ApiKeyInterface
SystemHealthInteface
BudgetInterface
GetTransaction(string, string, string) (*types.Transaction, error)
}
func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model {

View File

@@ -169,6 +169,31 @@ func (model *Model) getTransactionById(id string) (*types.Transaction, error) {
return model.db.GetTransactionById(id)
}
func (model *Model) GetTransaction(transactionId, orgId, userId string) (*types.Transaction, error) {
transaction, err := model.getTransactionById(transactionId)
if err != nil {
return nil, err
}
if transaction == nil || transaction.OrgId != orgId {
return nil, nil
}
// Check if user has access to all accounts in the transaction
userAccounts, err := model.GetAccounts(orgId, userId, "")
if err != nil {
return nil, err
}
for _, split := range transaction.Splits {
if !model.accountsContainReadAccess(userAccounts, split.AccountId) {
return nil, fmt.Errorf("user does not have permission to access account %s", split.AccountId)
}
}
return transaction, nil
}
func (model *Model) checkSplits(transaction *types.Transaction) (err error) {
if len(transaction.Splits) < 2 {
return errors.New("at least 2 splits are required")

View File

@@ -0,0 +1,20 @@
package types
import (
"time"
)
type Attachment struct {
Id string `json:"id"`
TransactionId string `json:"transactionId"`
OrgId string `json:"orgId"`
UserId string `json:"userId"`
FileName string `json:"fileName"`
OriginalName string `json:"originalName"`
ContentType string `json:"contentType"`
FileSize int64 `json:"fileSize"`
FilePath string `json:"filePath"`
Description string `json:"description"`
Uploaded time.Time `json:"uploaded"`
Deleted bool `json:"deleted"`
}

View File

@@ -3,6 +3,7 @@ package util
import (
"crypto/rand"
"encoding/hex"
"regexp"
"time"
)
@@ -44,3 +45,23 @@ func NewInviteId() (string, error) {
return hex.EncodeToString(byteArray), nil
}
func NewUUID() string {
guid, err := NewGuid()
if err != nil {
// Fallback to timestamp-based UUID if random generation fails
return hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
}
return guid
}
func IsValidUUID(uuid string) bool {
// Check if the string is a valid 32-character hex string (16 bytes * 2 hex chars)
if len(uuid) != 32 {
return false
}
// Check if all characters are valid hex characters
matched, _ := regexp.MatchString("^[0-9a-f]{32}$", uuid)
return matched
}

View File

@@ -67,7 +67,7 @@ func Handler(w rest.ResponseWriter, r *rest.Request) {
continue
}
log.Printf("recv: %s", message)
log.Printf("recv: %+v", message)
// check version
err = checkVersion(message.Version)

View File

@@ -3,4 +3,8 @@ CREATE INDEX split_accountId_index ON split (accountId);
CREATE INDEX split_transactionId_index ON split (transactionId);
CREATE INDEX split_date_index ON split (date);
CREATE INDEX split_updated_index ON split (updated);
CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId);
CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId);
CREATE INDEX attachment_transactionId_index ON attachment (transactionId);
CREATE INDEX attachment_orgId_index ON attachment (orgId);
CREATE INDEX attachment_userId_index ON attachment (userId);
CREATE INDEX attachment_uploaded_index ON attachment (uploaded);

28
models/attachment.go Normal file
View File

@@ -0,0 +1,28 @@
package models
import (
"time"
)
type Attachment struct {
ID []byte `gorm:"type:BINARY(16);primaryKey"`
TransactionID []byte `gorm:"column:transactionId;type:BINARY(16);not null"`
OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"`
UserID []byte `gorm:"column:userId;type:BINARY(16);not null"`
FileName string `gorm:"column:fileName;size:255;not null"`
OriginalName string `gorm:"column:originalName;size:255;not null"`
ContentType string `gorm:"column:contentType;size:100;not null"`
FileSize int64 `gorm:"column:fileSize;not null"`
FilePath string `gorm:"column:filePath;size:500;not null"`
Description string `gorm:"column:description;size:500"`
Uploaded time.Time `gorm:"column:uploaded;not null"`
Deleted bool `gorm:"column:deleted;default:false"`
Transaction Transaction `gorm:"foreignKey:TransactionID"`
Org Org `gorm:"foreignKey:OrgID"`
User User `gorm:"foreignKey:UserID"`
}
func (Attachment) TableName() string {
return "attachment"
}

View File

@@ -30,4 +30,6 @@ CREATE TABLE apikey (id BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL,
CREATE TABLE invite (id VARCHAR(32) NOT NULL, orgId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, email VARCHAR(100) NOT NULL, accepted BOOLEAN NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
CREATE TABLE attachment (id BINARY(16) NOT NULL, transactionId BINARY(16) NOT NULL, orgId BINARY(16) NOT NULL, userId BINARY(16) NOT NULL, fileName VARCHAR(255) NOT NULL, originalName VARCHAR(255) NOT NULL, contentType VARCHAR(100) NOT NULL, fileSize BIGINT NOT NULL, filePath VARCHAR(500) NOT NULL, description VARCHAR(500), uploaded BIGINT UNSIGNED NOT NULL, deleted BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY(id)) ENGINE=InnoDB;