full stack first commit

This commit is contained in:
Leon Bösche
2025-12-18 00:02:50 +01:00
parent ab7c734ae7
commit b35adc3d06
18 changed files with 717 additions and 85 deletions

View File

@@ -0,0 +1,25 @@
import 'package:bloc/bloc.dart';
import 'activity_event.dart';
import 'activity_state.dart';
import '../../services/activity_api.dart';
class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
final ActivityApi activityApi;
ActivityBloc(this.activityApi) : super(ActivityInitial()) {
on<LoadOrgActivities>(_onLoadOrgActivities);
}
void _onLoadOrgActivities(
LoadOrgActivities event,
Emitter<ActivityState> emit,
) async {
emit(ActivityLoading());
try {
final activities = await activityApi.getOrgActivities(event.orgId);
emit(ActivityLoaded(activities: activities));
} catch (e) {
emit(ActivityError(e.toString()));
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class ActivityEvent extends Equatable {
const ActivityEvent();
@override
List<Object> get props => [];
}
class LoadOrgActivities extends ActivityEvent {
final String orgId;
const LoadOrgActivities(this.orgId);
@override
List<Object> get props => [orgId];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../organization/organization_state.dart';
abstract class ActivityState extends Equatable {
const ActivityState();
@override
List<Object> get props => [];
}
class ActivityInitial extends ActivityState {}
class ActivityLoading extends ActivityState {}
class ActivityLoaded extends ActivityState {
final List<Activity> activities;
const ActivityLoaded({required this.activities});
@override
List<Object> get props => [activities];
}
class ActivityError extends ActivityState {
final String error;
const ActivityError(this.error);
@override
List<Object> get props => [error];
}

View File

@@ -11,14 +11,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
// Simulate API call to go.b0esche.cloud/auth/login
await Future.delayed(const Duration(seconds: 1));
if (event.email.isNotEmpty && event.password.isNotEmpty) {
// Assume JWT received
emit(AuthAuthenticated(token: 'fake-jwt', userId: 'user123'));
} else {
emit(const AuthFailure('Invalid credentials'));
}
// Redirect to Go auth/login
// For web, use window.location or url_launcher
// Assume handled in UI
emit(const AuthFailure('Redirect to login URL'));
}
void _onLogoutRequested(

View File

@@ -7,15 +7,7 @@ abstract class AuthEvent extends Equatable {
List<Object> get props => [];
}
class LoginRequested extends AuthEvent {
final String email;
final String password;
const LoginRequested(this.email, this.password);
@override
List<Object> get props => [email, password];
}
class LoginRequested extends AuthEvent {}
class LogoutRequested extends AuthEvent {}

View File

@@ -8,14 +8,20 @@ import '../file_browser/file_browser_bloc.dart';
import '../file_browser/file_browser_event.dart';
import '../upload/upload_bloc.dart';
import '../upload/upload_event.dart';
import '../../services/org_api.dart';
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
final PermissionBloc permissionBloc;
final FileBrowserBloc fileBrowserBloc;
final UploadBloc uploadBloc;
final OrgApi orgApi;
OrganizationBloc(this.permissionBloc, this.fileBrowserBloc, this.uploadBloc)
: super(OrganizationInitial()) {
OrganizationBloc(
this.permissionBloc,
this.fileBrowserBloc,
this.uploadBloc,
this.orgApi,
) : super(OrganizationInitial()) {
on<LoadOrganizations>(_onLoadOrganizations);
on<SelectOrganization>(_onSelectOrganization);
on<CreateOrganization>(_onCreateOrganization);
@@ -27,14 +33,13 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
) async {
emit(OrganizationLoading());
try {
// Simulate loading orgs from API
await Future.delayed(const Duration(seconds: 1));
final orgs = [
const Organization(id: 'org1', name: 'Personal', role: 'admin'),
const Organization(id: 'org2', name: 'Company Inc', role: 'edit'),
const Organization(id: 'org3', name: 'Side Project', role: 'admin'),
];
emit(OrganizationLoaded(organizations: orgs, selectedOrg: orgs.first));
final orgs = await orgApi.getOrganizations();
emit(
OrganizationLoaded(
organizations: orgs,
selectedOrg: orgs.isNotEmpty ? orgs.first : null,
),
);
} catch (e) {
emit(OrganizationError(e.toString()));
}
@@ -103,13 +108,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
),
);
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
final newOrg = Organization(
id: 'org${currentState.organizations.length + 1}',
name: name,
role: 'admin',
);
final newOrg = await orgApi.createOrganization(name);
final updatedOrgs = [...currentState.organizations, newOrg];
emit(
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),

View File

@@ -3,20 +3,70 @@ import 'package:equatable/equatable.dart';
class Organization extends Equatable {
final String id;
final String name;
final String role; // view, edit, admin
final String? shortCode;
final int? color;
final String slug;
final DateTime createdAt;
const Organization({
required this.id,
required this.name,
required this.role,
this.shortCode,
this.color,
required this.slug,
required this.createdAt,
});
@override
List<Object?> get props => [id, name, role, shortCode, color];
List<Object?> get props => [id, name, slug, createdAt];
factory Organization.fromJson(Map<String, dynamic> json) {
return Organization(
id: json['id'],
name: json['name'],
slug: json['slug'],
createdAt: DateTime.parse(json['createdAt']),
);
}
}
class Activity extends Equatable {
final String id;
final String userId;
final String orgId;
final String? fileId;
final String action;
final Map<String, dynamic> metadata;
final DateTime timestamp;
const Activity({
required this.id,
required this.userId,
required this.orgId,
this.fileId,
required this.action,
required this.metadata,
required this.timestamp,
});
@override
List<Object?> get props => [
id,
userId,
orgId,
fileId,
action,
metadata,
timestamp,
];
factory Activity.fromJson(Map<String, dynamic> json) {
return Activity(
id: json['id'],
userId: json['userId'],
orgId: json['orgId'],
fileId: json['fileId'],
action: json['action'],
metadata: json['metadata'] ?? {},
timestamp: DateTime.parse(json['timestamp']),
);
}
}
abstract class OrganizationState extends Equatable {

View File

@@ -15,8 +15,8 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
final expiresAt = DateTime.now().add(
const Duration(hours: 1),
); // Fake expiry
const Duration(minutes: 15),
); // Match Go
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
@@ -27,7 +27,7 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
}
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
final expiresAt = DateTime.now().add(const Duration(hours: 1));
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}

View File

@@ -7,8 +7,12 @@ import 'blocs/organization/organization_bloc.dart';
import 'blocs/permission/permission_bloc.dart';
import 'blocs/file_browser/file_browser_bloc.dart';
import 'blocs/upload/upload_bloc.dart';
import 'blocs/activity/activity_bloc.dart';
import 'repositories/mock_file_repository.dart';
import 'services/file_service.dart';
import 'services/api_client.dart';
import 'services/org_api.dart';
import 'services/activity_api.dart';
import 'pages/home_page.dart';
import 'pages/login_form.dart';
import 'pages/file_explorer.dart';
@@ -58,10 +62,13 @@ class MainApp extends StatelessWidget {
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
BlocProvider<FileBrowserBloc>(
create: (_) => FileBrowserBloc(FileService(MockFileRepository())),
create: (context) => FileBrowserBloc(
FileService(ApiClient(context.read<SessionBloc>())),
),
),
BlocProvider<UploadBloc>(
create: (_) => UploadBloc(MockFileRepository()),
create: (_) =>
UploadBloc(MockFileRepository()), // keep mock for upload
),
BlocProvider<OrganizationBloc>(
lazy: true,
@@ -69,8 +76,13 @@ class MainApp extends StatelessWidget {
context.read<PermissionBloc>(),
context.read<FileBrowserBloc>(),
context.read<UploadBloc>(),
OrgApi(ApiClient(context.read<SessionBloc>())),
),
),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
),
],
child: MaterialApp.router(
routerConfig: _router,

View File

@@ -110,8 +110,8 @@ class _LoginFormState extends State<LoginForm> {
onPressed: () {
context.read<AuthBloc>().add(
LoginRequested(
_emailController.text,
_passwordController.text,
// _emailController.text,
// _passwordController.text,
),
);
},

View File

@@ -0,0 +1,15 @@
import '../blocs/organization/organization_state.dart';
import 'api_client.dart';
class ActivityApi {
final ApiClient _apiClient;
ActivityApi(this._apiClient);
Future<List<Activity>> getOrgActivities(String orgId) async {
return await _apiClient.getList(
'/orgs/$orgId/activity',
fromJson: (data) => Activity.fromJson(data),
);
}
}

View File

@@ -102,6 +102,19 @@ class ApiClient {
}
}
Future<List<T>> getList<T>(
String path, {
Map<String, dynamic>? queryParameters,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
return (response.data as List).map(fromJson).toList();
} on DioException catch (e) {
throw _handleError(e);
}
}
ApiError _handleError(DioException e) {
final status = e.response?.statusCode;
final data = e.response?.data;

View File

@@ -3,38 +3,39 @@ import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../repositories/file_repository.dart';
import 'api_client.dart';
class FileService {
final FileRepository _fileRepository;
final ApiClient _apiClient;
FileService(this._fileRepository);
FileService(this._apiClient);
Future<List<FileItem>> getFiles(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
return await _fileRepository.getFiles(orgId, path);
return await _apiClient.getList(
'/orgs/$orgId/files',
fromJson: (data) => FileItem(
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
size: data['size'],
lastModified: DateTime.parse(data['lastModified']),
),
);
}
Future<FileItem?> getFile(String orgId, String path) async {
if (path.isEmpty) {
return null;
}
return await _fileRepository.getFile(orgId, path);
throw UnimplementedError();
}
Future<void> uploadFile(String orgId, FileItem file) async {
if (file.name.isEmpty) {
throw Exception('File name cannot be empty');
}
await _fileRepository.uploadFile(orgId, file);
throw UnimplementedError();
}
Future<void> deleteFile(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
await _fileRepository.deleteFile(orgId, path);
throw UnimplementedError();
}
Future<void> createFolder(
@@ -42,10 +43,7 @@ class FileService {
String parentPath,
String folderName,
) async {
if (folderName.isEmpty) {
throw Exception('Folder name cannot be empty');
}
await _fileRepository.createFolder(orgId, parentPath, folderName);
throw UnimplementedError();
}
Future<void> moveFile(
@@ -53,24 +51,15 @@ class FileService {
String sourcePath,
String targetPath,
) async {
if (sourcePath.isEmpty || targetPath.isEmpty) {
throw Exception('Paths cannot be empty');
}
await _fileRepository.moveFile(orgId, sourcePath, targetPath);
throw UnimplementedError();
}
Future<void> renameFile(String orgId, String path, String newName) async {
if (path.isEmpty || newName.isEmpty) {
throw Exception('Path and new name cannot be empty');
}
await _fileRepository.renameFile(orgId, path, newName);
throw UnimplementedError();
}
Future<List<FileItem>> searchFiles(String orgId, String query) async {
if (query.isEmpty) {
return [];
}
return await _fileRepository.searchFiles(orgId, query);
throw UnimplementedError();
}
Future<ViewerSession> requestViewerSession(
@@ -80,7 +69,10 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
return await _fileRepository.requestViewerSession(orgId, fileId);
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/view',
fromJson: (data) => ViewerSession.fromJson(data),
);
}
Future<EditorSession> requestEditorSession(
@@ -90,7 +82,10 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
return await _fileRepository.requestEditorSession(orgId, fileId);
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/edit',
fromJson: (data) => EditorSession.fromJson(data),
);
}
Future<void> saveAnnotations(
@@ -101,6 +96,13 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
await _fileRepository.saveAnnotations(orgId, fileId, annotations);
await _apiClient.post(
'/orgs/$orgId/files/$fileId/annotations',
data: {
'annotations': annotations.map((a) => a.toJson()).toList(),
'baseVersionId': '1', // mock
},
fromJson: (data) => null,
);
}
}

View File

@@ -0,0 +1,23 @@
import '../blocs/organization/organization_state.dart';
import 'api_client.dart';
class OrgApi {
final ApiClient _apiClient;
OrgApi(this._apiClient);
Future<List<Organization>> getOrganizations() async {
return await _apiClient.getList(
'/orgs',
fromJson: (data) => Organization.fromJson(data),
);
}
Future<Organization> createOrganization(String name) async {
return await _apiClient.post(
'/orgs',
data: {'name': name},
fromJson: (data) => Organization.fromJson(data),
);
}
}

View File

@@ -45,6 +45,16 @@ type Membership struct {
CreatedAt time.Time
}
type Activity struct {
ID uuid.UUID
UserID uuid.UUID
OrgID uuid.UUID
FileID *string
Action string
Metadata map[string]interface{}
Timestamp time.Time
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
@@ -122,3 +132,89 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
}
return &membership, nil
}
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
var org Organization
err := db.QueryRowContext(ctx, `
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
if err != nil {
return nil, err
}
return &org, nil
}
func (db *DB) AddMembership(ctx context.Context, userID, orgID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO memberships (user_id, org_id, role)
VALUES ($1, $2, $3)
`, userID, orgID, role)
return err
}
func (db *DB) LogActivity(ctx context.Context, userID, orgID uuid.UUID, fileID *string, action string, metadata map[string]interface{}) error {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (user_id, org_id, file_id, action, metadata)
VALUES ($1, $2, $3, $4, $5)
`, userID, orgID, fileID, action, metadata)
return err
}
func (db *DB) GetOrgActivities(ctx context.Context, orgID uuid.UUID, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, org_id, file_id, action, metadata, timestamp
FROM activities
WHERE org_id = $1
ORDER BY timestamp DESC
LIMIT $2
`, orgID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []Activity
for rows.Next() {
var a Activity
err := rows.Scan(&a.ID, &a.UserID, &a.OrgID, &a.FileID, &a.Action, &a.Metadata, &a.Timestamp)
if err != nil {
return nil, err
}
activities = append(activities, a)
}
return activities, rows.Err()
}
func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, error) {
rows, err := db.QueryContext(ctx, `
SELECT user_id, org_id, role, created_at
FROM memberships
WHERE org_id = $1
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var memberships []Membership
for rows.Next() {
var m Membership
err := rows.Scan(&m.UserID, &m.OrgID, &m.Role, &m.CreatedAt)
if err != nil {
return nil, err
}
memberships = append(memberships, m)
}
return memberships, rows.Err()
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
SET role = $1
WHERE org_id = $2 AND user_id = $3
`, role, orgID, userID)
return err
}

View File

@@ -1,16 +1,21 @@
package http
import (
"encoding/json"
"net/http"
"strings"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
@@ -31,13 +36,57 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
authLoginHandler(w, req, authService)
})
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger)
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger, db)
})
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
refreshHandler(w, req, jwtManager, db)
})
})
// Auth middleware for protected routes
r.Use(middleware.Auth(jwtManager, db))
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db, jwtManager)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger, jwtManager)
})
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
})
return r
}
@@ -59,7 +108,7 @@ func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.
http.Redirect(w, r, url, http.StatusFound)
}
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger, db *database.DB) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
@@ -76,7 +125,18 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
return
}
token, err := jwtManager.Generate(user.Email, []string{}, session.ID.String()) // Orgs not yet
// Get user orgs
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
http.Error(w, "Token generation failed", http.StatusInternalServerError)
return
@@ -91,3 +151,277 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + token + `"}`))
}
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + newToken + `"}`))
}
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs)
}
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
var req struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &org.ID,
Action: "create_org",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(org)
}
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
// Mock files
files := []struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int `json:"size"`
LastModified string `json:"lastModified"`
}{
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
session := struct {
ViewUrl string `json:"viewUrl"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
} `json:"capabilities"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: "https://view.example.com/" + fileId,
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
ExpiresAt: "2023-01-01T01:00:00Z",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
session := struct {
EditUrl string `json:"editUrl"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: "https://edit.example.com/" + fileId,
ReadOnly: false,
ExpiresAt: "2023-01-01T01:00:00Z",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Parse payload
var payload struct {
Annotations []interface{} `json:"annotations"`
BaseVersionId string `json:"baseVersionId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Log activity
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Resource: &fileId,
Action: "annotate_pdf",
Success: true,
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value("org").(uuid.UUID)
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(activities)
}
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value("org").(uuid.UUID)
members, err := db.GetOrgMembers(r.Context(), orgID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members)
}
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value("org").(uuid.UUID)
userIDStr := chi.URLParam(r, "userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
meta := struct {
LastModified string `json:"lastModified"`
LastModifiedBy string `json:"lastModifiedBy"`
VersionCount int `json:"versionCount"`
}{
LastModified: "2023-01-01T00:00:00Z",
LastModifiedBy: "user@example.com",
VersionCount: 1,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}

View File

@@ -21,3 +21,20 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
}
return membership.Role, nil
}
// CreateOrg creates a new organization and adds the user as owner
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
if slug == "" {
// Simple slug generation
slug = name // TODO: make URL safe
}
org, err := db.CreateOrg(ctx, name, slug)
if err != nil {
return nil, err
}
err = db.AddMembership(ctx, userID, org.ID, "owner")
if err != nil {
return nil, err
}
return org, nil
}

View File

@@ -39,4 +39,14 @@ CREATE TABLE audit_logs (
success BOOLEAN NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB
);
CREATE TABLE activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
org_id UUID REFERENCES organizations(id),
file_id TEXT, -- nullable, for future file table
action TEXT NOT NULL,
metadata JSONB,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);