diff --git a/assets/js/views/ProjectManagement.vue b/assets/js/views/ProjectManagement.vue
index 743ed47..ccb748d 100644
--- a/assets/js/views/ProjectManagement.vue
+++ b/assets/js/views/ProjectManagement.vue
@@ -278,6 +278,58 @@
+
+
Dokumente
@@ -796,6 +848,8 @@ const deleting = ref(false)
const typeFilter = ref('all')
const customers = ref([])
const statuses = ref([])
+const users = ref([])
+const currentUser = ref(null)
const gitRepositories = ref([])
const selectedRepository = ref(null)
const selectedBranch = ref('main')
@@ -844,7 +898,7 @@ const projectColumns = ref([
])
onMounted(async () => {
- await Promise.all([loadCustomers(), loadStatuses()])
+ await Promise.all([loadCustomers(), loadStatuses(), loadUsers(), loadCurrentUser()])
})
async function loadCustomers() {
@@ -891,6 +945,39 @@ async function loadStatuses() {
}
}
+async function loadUsers() {
+ try {
+ const response = await fetch('/api/users?pagination=false')
+ if (!response.ok) throw new Error('Fehler beim Laden der Benutzer')
+
+ const data = await response.json()
+ const usersList = data['hydra:member'] || data.member || data
+
+ users.value = Array.isArray(usersList) ? usersList : []
+ } catch (error) {
+ console.error('Error loading users:', error)
+ users.value = []
+ toast.add({
+ severity: 'error',
+ summary: 'Fehler',
+ detail: 'Benutzer konnten nicht geladen werden',
+ life: 3000
+ })
+ }
+}
+
+async function loadCurrentUser() {
+ try {
+ const response = await fetch('/api/me')
+ if (!response.ok) throw new Error('Fehler beim Laden des aktuellen Benutzers')
+
+ currentUser.value = await response.json()
+ } catch (error) {
+ console.error('Error loading current user:', error)
+ currentUser.value = null
+ }
+}
+
function filterByType(type, loadData) {
typeFilter.value = type
@@ -960,7 +1047,9 @@ function openNewProjectDialog() {
endDate: null,
budget: null,
hourContingent: null,
- isPrivate: false
+ isPrivate: false,
+ owner: currentUser.value,
+ teamMembers: []
}
submitted.value = false
projectDialog.value = true
@@ -974,7 +1063,8 @@ function editProject(project) {
startDate: project.startDate ? new Date(project.startDate) : null,
endDate: project.endDate ? new Date(project.endDate) : null,
budget: project.budget ? parseFloat(project.budget) : null,
- hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null
+ hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null,
+ teamMembers: project.teamMembers || []
}
submitted.value = false
projectDialog.value = true
@@ -1218,7 +1308,7 @@ async function deleteGitRepo() {
async function saveProject() {
submitted.value = true
- if (!editingProject.value.name) {
+ if (!editingProject.value.name || !editingProject.value.owner) {
return
}
@@ -1238,7 +1328,9 @@ async function saveProject() {
endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null,
budget: editingProject.value.budget ? editingProject.value.budget.toString() : null,
hourContingent: editingProject.value.hourContingent ? editingProject.value.hourContingent.toString() : null,
- isPrivate: editingProject.value.isPrivate
+ isPrivate: editingProject.value.isPrivate,
+ owner: `/api/users/${editingProject.value.owner.id}`,
+ teamMembers: editingProject.value.teamMembers?.map(member => `/api/users/${member.id}`) || []
}
const isNew = !editingProject.value.id
diff --git a/bruno/Git Integration/Create Repository.bru b/bruno/Git Integration/Create Repository.bru
new file mode 100644
index 0000000..55fe7a4
--- /dev/null
+++ b/bruno/Git Integration/Create Repository.bru
@@ -0,0 +1,65 @@
+meta {
+ name: Create Repository
+ type: http
+ seq: 7
+}
+
+post {
+ url: {{baseUrl}}/api/git_repositories
+ body: json
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Content-Type: application/json
+ Cookie: {{sessionCookie}}
+}
+
+body:json {
+ {
+ "name": "My Project Repository",
+ "provider": "github",
+ "owner": "username",
+ "repo": "repository-name",
+ "branch": "main",
+ "project": "/api/projects/1",
+ "personalAccessToken": "ghp_xxxxxxxxxxxx"
+ }
+}
+
+docs {
+ # Create Repository
+
+ Creates a new Git repository entry.
+
+ ## Body Parameters
+ - `name`: Display name for the repository
+ - `provider`: One of 'github', 'gitea', 'local'
+ - `owner`: Repository owner/organization
+ - `repo`: Repository name
+ - `branch`: Default branch (usually 'main' or 'master')
+ - `project`: IRI reference to project (e.g., "/api/projects/1")
+ - `personalAccessToken`: Optional access token for private repos
+
+ ## Provider-Specific Settings
+
+ ### GitHub
+ - `owner`: GitHub username or organization
+ - `repo`: Repository name
+ - `personalAccessToken`: GitHub Personal Access Token (optional for public repos)
+
+ ### Gitea
+ - `owner`: Gitea username or organization
+ - `repo`: Repository name
+ - `personalAccessToken`: Gitea API token
+ - Requires `giteaUrl` in environment
+
+ ### Local
+ - `localPath`: Absolute path to local Git repository
+ - No owner/repo needed
+
+ ## Security
+ - Requires authentication
+ - Token is stored encrypted in database
+}
diff --git a/bruno/Git Integration/Delete Repository.bru b/bruno/Git Integration/Delete Repository.bru
new file mode 100644
index 0000000..3a7816d
--- /dev/null
+++ b/bruno/Git Integration/Delete Repository.bru
@@ -0,0 +1,32 @@
+meta {
+ name: Delete Repository
+ type: http
+ seq: 9
+}
+
+delete {
+ url: {{baseUrl}}/api/git_repositories/1
+ body: none
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Delete Repository
+
+ Deletes a Git repository entry.
+
+ ## Security
+ - Requires authentication via session cookie
+ - Voter checks DELETE permission
+ - Only accessible if repository belongs to user's project
+
+ ## Important
+ - This only deletes the database entry
+ - The actual Git repository remains untouched
+ - Cache for this repository is automatically cleared
+}
diff --git a/bruno/Git Integration/Get Commits.bru b/bruno/Git Integration/Get Commits.bru
new file mode 100644
index 0000000..51b9773
--- /dev/null
+++ b/bruno/Git Integration/Get Commits.bru
@@ -0,0 +1,52 @@
+meta {
+ name: Get Commits
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{baseUrl}}/api/git-repos/1/commits?branch=main&limit=50
+ body: none
+ auth: none
+}
+
+params:query {
+ branch: main
+ limit: 50
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Get Commits
+
+ Fetches commit history for a specific repository and branch.
+
+ ## Query Parameters
+ - `branch`: Git branch name (default: 'main')
+ - `limit`: Number of commits to fetch (default: 100)
+
+ ## Response
+ ```json
+ {
+ "commits": [
+ {
+ "hash": "abc123def456...",
+ "shortHash": "abc123d",
+ "subject": "Add new feature",
+ "body": "Detailed commit message...",
+ "author": "John Doe",
+ "email": "john@example.com",
+ "date": "2025-11-14T10:30:00+00:00"
+ }
+ ]
+ }
+ ```
+
+ ## Caching
+ - Cached for 15 minutes (900 seconds)
+ - Cache key includes repository ID, branch, and limit
+}
diff --git a/bruno/Git Integration/Get Contributions.bru b/bruno/Git Integration/Get Contributions.bru
new file mode 100644
index 0000000..c980ff2
--- /dev/null
+++ b/bruno/Git Integration/Get Contributions.bru
@@ -0,0 +1,62 @@
+meta {
+ name: Get Contributions
+ type: http
+ seq: 5
+}
+
+get {
+ url: {{baseUrl}}/api/git-repos/1/contributions?branch=main&year=2025
+ body: none
+ auth: none
+}
+
+params:query {
+ branch: main
+ year: 2025
+ ~author: john@example.com
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Get Contributions
+
+ Fetches daily contribution data for a specific repository, branch, and year.
+ Used for generating GitHub-style contribution heatmaps.
+
+ ## Query Parameters
+ - `branch`: Git branch name (default: 'main')
+ - `year`: Year for contributions (default: current year)
+ - `author`: Optional email filter for specific author
+
+ ## Response
+ ```json
+ {
+ "contributions": [
+ {
+ "date": "2025-01-01",
+ "count": 3,
+ "weekday": 3
+ },
+ {
+ "date": "2025-01-02",
+ "count": 0,
+ "weekday": 4
+ }
+ ]
+ }
+ ```
+
+ ## Weekday Format
+ - 0 = Sunday
+ - 1 = Monday
+ - ...
+ - 6 = Saturday
+
+ ## Caching
+ - Cached for 1 hour (3600 seconds)
+ - Cache key includes repository ID, branch, author, and year
+}
diff --git a/bruno/Git Integration/Get Git Repositories.bru b/bruno/Git Integration/Get Git Repositories.bru
new file mode 100644
index 0000000..e6fb94f
--- /dev/null
+++ b/bruno/Git Integration/Get Git Repositories.bru
@@ -0,0 +1,34 @@
+meta {
+ name: Get Git Repositories
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/api/git_repositories?project=4
+ body: none
+ auth: none
+}
+
+params:query {
+ project: 4
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Get Git Repositories
+
+ Fetches all Git repositories associated with a specific project.
+
+ ## Query Parameters
+ - `project`: Project ID (required for security filtering)
+
+ ## Security
+ - Requires authentication via session cookie
+ - SearchFilter ensures only repositories for the specified project are returned
+ - Voter checks are applied on individual items
+}
diff --git a/bruno/Git Integration/Get Single Repository.bru b/bruno/Git Integration/Get Single Repository.bru
new file mode 100644
index 0000000..3cbc531
--- /dev/null
+++ b/bruno/Git Integration/Get Single Repository.bru
@@ -0,0 +1,27 @@
+meta {
+ name: Get Single Repository
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/api/git_repositories/1
+ body: none
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Get Single Repository
+
+ Fetches a specific Git repository by ID.
+
+ ## Security
+ - Requires authentication via session cookie
+ - Voter checks VIEW permission
+ - Only accessible if repository belongs to user's project
+}
diff --git a/bruno/Git Integration/Refresh Cache.bru b/bruno/Git Integration/Refresh Cache.bru
new file mode 100644
index 0000000..e792343
--- /dev/null
+++ b/bruno/Git Integration/Refresh Cache.bru
@@ -0,0 +1,36 @@
+meta {
+ name: Refresh Cache
+ type: http
+ seq: 6
+}
+
+post {
+ url: {{baseUrl}}/api/git-repos/1/refresh-cache
+ body: none
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Refresh Cache
+
+ Manually clears the cache for a specific repository.
+ Forces fresh data on next request for commits and contributions.
+
+ ## Response
+ ```json
+ {
+ "success": true,
+ "message": "Cache cleared successfully"
+ }
+ ```
+
+ ## Use Cases
+ - After pushing new commits
+ - When data appears outdated
+ - Testing/development
+}
diff --git a/bruno/Git Integration/Test Repository Connection.bru b/bruno/Git Integration/Test Repository Connection.bru
new file mode 100644
index 0000000..8f36567
--- /dev/null
+++ b/bruno/Git Integration/Test Repository Connection.bru
@@ -0,0 +1,44 @@
+meta {
+ name: Test Repository Connection
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{baseUrl}}/api/git-repos/1/test
+ body: none
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Cookie: {{sessionCookie}}
+}
+
+docs {
+ # Test Repository Connection
+
+ Tests if the repository connection is working correctly by fetching the first 10 commits.
+
+ ## Response
+ ```json
+ {
+ "success": true,
+ "message": "Repository connection successful!",
+ "details": {
+ "commits": 10,
+ "firstCommit": "abc123d: Add new feature",
+ "provider": "github|gitea|local"
+ }
+ }
+ ```
+
+ ## Error Response
+ ```json
+ {
+ "success": false,
+ "error": "Connection failed: ...",
+ "hint": "Check owner/repo/branch settings"
+ }
+ ```
+}
diff --git a/bruno/Git Integration/Update Repository.bru b/bruno/Git Integration/Update Repository.bru
new file mode 100644
index 0000000..302d62d
--- /dev/null
+++ b/bruno/Git Integration/Update Repository.bru
@@ -0,0 +1,38 @@
+meta {
+ name: Update Repository
+ type: http
+ seq: 8
+}
+
+put {
+ url: {{baseUrl}}/api/git_repositories/1
+ body: json
+ auth: none
+}
+
+headers {
+ Accept: application/json
+ Content-Type: application/json
+ Cookie: {{sessionCookie}}
+}
+
+body:json {
+ {
+ "name": "Updated Repository Name",
+ "branch": "develop"
+ }
+}
+
+docs {
+ # Update Repository
+
+ Updates an existing Git repository entry.
+
+ ## Security
+ - Requires authentication via session cookie
+ - Voter checks EDIT permission
+ - Only accessible if repository belongs to user's project
+
+ ## Partial Updates
+ You can send only the fields you want to update.
+}
diff --git a/bruno/README.md b/bruno/README.md
new file mode 100644
index 0000000..d534785
--- /dev/null
+++ b/bruno/README.md
@@ -0,0 +1,148 @@
+# myCRM Bruno API Collection
+
+Diese Bruno-Collection enthält alle API-Endpunkte für die myCRM-Anwendung.
+
+## Setup
+
+1. **Bruno installieren**
+ ```bash
+ # Via npm
+ npm install -g @usebruno/cli
+
+ # Oder Desktop-App von https://www.usebruno.com/ herunterladen
+ ```
+
+2. **Collection öffnen**
+ - Bruno Desktop-App öffnen
+ - "Open Collection" wählen
+ - Diesen `bruno/` Ordner auswählen
+
+3. **Environment konfigurieren**
+ - In Bruno: Environment "local" auswählen
+ - Session-Cookie holen:
+ ```bash
+ # Option 1: Aus Browser DevTools kopieren
+ # 1. In Browser bei localhost:8000 einloggen
+ # 2. DevTools → Application → Cookies
+ # 3. PHPSESSID kopieren
+
+ # Option 2: Via Symfony CLI
+ symfony server:log
+ # Nach Login den Cookie aus den Logs kopieren
+ ```
+ - In `environments/local.bru` den `sessionCookie` Wert eintragen
+
+## Verfügbare Sammlungen
+
+### Git Integration
+Alle Endpunkte für die Git-Repository-Integration:
+
+- **Get Git Repositories** - Liste aller Repositories eines Projekts
+- **Get Single Repository** - Details eines einzelnen Repositories
+- **Test Repository Connection** - Verbindung testen (10 Commits)
+- **Get Commits** - Commit-Historie abrufen
+- **Get Contributions** - Tägliche Contributions für Heatmap
+- **Refresh Cache** - Cache manuell leeren
+- **Create Repository** - Neues Repository anlegen
+- **Update Repository** - Repository aktualisieren
+- **Delete Repository** - Repository löschen
+
+## Authentifizierung
+
+Die API verwendet Session-basierte Authentifizierung:
+
+```
+Cookie: PHPSESSID=your_session_id_here
+```
+
+Die Session wird automatisch von allen Requests verwendet, wenn sie im Environment konfiguriert ist.
+
+## Sicherheit
+
+Alle Endpunkte sind durch Symfony Security und Voter geschützt:
+
+- **Authentication**: Session-Cookie erforderlich
+- **Authorization**: GitRepositoryVoter prüft VIEW/EDIT/DELETE Rechte
+- **Project-Filtering**: Nur Repositories des eigenen Projekts sind sichtbar
+
+## Caching
+
+Die Git-Integration verwendet Symfony Cache:
+
+- **Commits**: 15 Minuten (900s)
+- **Contributions**: 1 Stunde (3600s)
+- **Manuelles Löschen**: POST `/api/git-repos/{id}/refresh-cache`
+
+## Provider-Typen
+
+### GitHub
+```json
+{
+ "provider": "github",
+ "owner": "username",
+ "repo": "repository",
+ "personalAccessToken": "ghp_xxxxx"
+}
+```
+
+### Gitea
+```json
+{
+ "provider": "gitea",
+ "owner": "username",
+ "repo": "repository",
+ "personalAccessToken": "gitea_token",
+ "giteaUrl": "https://gitea.example.com"
+}
+```
+
+### Local
+```json
+{
+ "provider": "local",
+ "localPath": "/absolute/path/to/repo"
+}
+```
+
+## Testing
+
+1. **Repository-Verbindung testen**:
+ ```
+ POST /api/git-repos/{id}/test
+ ```
+
+2. **Commits abrufen**:
+ ```
+ GET /api/git-repos/{id}/commits?branch=main&limit=50
+ ```
+
+3. **Contributions für Heatmap**:
+ ```
+ GET /api/git-repos/{id}/contributions?branch=main&year=2025
+ ```
+
+## Troubleshooting
+
+### 401 Unauthorized
+- Session-Cookie abgelaufen → Neu einloggen und Cookie aktualisieren
+- Cookie falsch formatiert → Format: `PHPSESSID=value`
+
+### 403 Forbidden
+- Voter verweigert Zugriff → Repository gehört nicht zum eigenen Projekt
+- Fehlende Berechtigungen → Projekt-Zugriff prüfen
+
+### 404 Not Found
+- Repository existiert nicht
+- Falsche ID verwendet
+- Repository wurde gelöscht
+
+### 500 Internal Server Error
+- Git-Fehler → Repository-Einstellungen prüfen (owner/repo/branch)
+- Token ungültig → Personal Access Token erneuern
+- Cache-Fehler → `var/cache/` Ordner prüfen
+
+## Weitere Informationen
+
+- [Symfony API Platform Docs](https://api-platform.com/)
+- [Bruno Documentation](https://docs.usebruno.com/)
+- [Git Integration Docs](../docs/GIT_INTEGRATION.md)
diff --git a/bruno/bruno.json b/bruno/bruno.json
new file mode 100644
index 0000000..d4692ae
--- /dev/null
+++ b/bruno/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "myCRM API",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru
new file mode 100644
index 0000000..849ecd3
--- /dev/null
+++ b/bruno/environments/local.bru
@@ -0,0 +1,6 @@
+vars {
+ baseUrl: http://localhost:8000
+ # Get this from your browser's developer tools after login
+ # Or use symfony CLI to get session cookie
+ sessionCookie: PHPSESSID=f5kmgf6khpmo106vkikupmpuc5
+}
diff --git a/bruno/environments/myCRM.bru b/bruno/environments/myCRM.bru
new file mode 100644
index 0000000..53adc74
--- /dev/null
+++ b/bruno/environments/myCRM.bru
@@ -0,0 +1,4 @@
+vars {
+ baseUrl: https://mycrm.test
+ PHPSESSID: f5kmgf6khpmo106vkikupmpuc5
+}
diff --git a/migrations/Version20251114102103.php b/migrations/Version20251114102103.php
new file mode 100644
index 0000000..f4e3e8d
--- /dev/null
+++ b/migrations/Version20251114102103.php
@@ -0,0 +1,81 @@
+addSql('SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND COLUMN_NAME = "owner_id")');
+ $this->addSql('SET @sql = IF(@col_exists = 0, "ALTER TABLE projects ADD owner_id INT DEFAULT NULL", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+
+ // Set first user as owner for existing projects
+ $this->addSql('UPDATE projects SET owner_id = (SELECT id FROM users ORDER BY id LIMIT 1) WHERE owner_id IS NULL');
+
+ // Make owner_id NOT NULL
+ $this->addSql('SET @sql = IF(@col_exists = 0, "ALTER TABLE projects MODIFY owner_id INT NOT NULL", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+
+ // Add foreign key if not exists
+ $this->addSql('SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND CONSTRAINT_NAME = "FK_5C93B3A47E3C61F9")');
+ $this->addSql('SET @sql = IF(@fk_exists = 0, "ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A47E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+
+ // Add index if not exists
+ $this->addSql('SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "projects" AND INDEX_NAME = "IDX_5C93B3A47E3C61F9")');
+ $this->addSql('SET @sql = IF(@idx_exists = 0, "CREATE INDEX IDX_5C93B3A47E3C61F9 ON projects (owner_id)", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+
+ // Create junction table for team members if not exists
+ $this->addSql('CREATE TABLE IF NOT EXISTS project_team_members (project_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_8A3C5F7D166D1F9C (project_id), INDEX IDX_8A3C5F7DA76ED395 (user_id), PRIMARY KEY(project_id, user_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+
+ // Add foreign keys for junction table if not exists
+ $this->addSql('SET @fk1_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "project_team_members" AND CONSTRAINT_NAME = "FK_8A3C5F7D166D1F9C")');
+ $this->addSql('SET @sql = IF(@fk1_exists = 0, "ALTER TABLE project_team_members ADD CONSTRAINT FK_8A3C5F7D166D1F9C FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+
+ $this->addSql('SET @fk2_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "project_team_members" AND CONSTRAINT_NAME = "FK_8A3C5F7DA76ED395")');
+ $this->addSql('SET @sql = IF(@fk2_exists = 0, "ALTER TABLE project_team_members ADD CONSTRAINT FK_8A3C5F7DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE", "SELECT 1")');
+ $this->addSql('PREPARE stmt FROM @sql');
+ $this->addSql('EXECUTE stmt');
+ $this->addSql('DEALLOCATE PREPARE stmt');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Drop junction table
+ $this->addSql('ALTER TABLE project_team_members DROP FOREIGN KEY FK_8A3C5F7D166D1F9C');
+ $this->addSql('ALTER TABLE project_team_members DROP FOREIGN KEY FK_8A3C5F7DA76ED395');
+ $this->addSql('DROP TABLE project_team_members');
+
+ // Remove owner_id from projects
+ $this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A47E3C61F9');
+ $this->addSql('DROP INDEX IDX_5C93B3A47E3C61F9 ON projects');
+ $this->addSql('ALTER TABLE projects DROP owner_id');
+ }
+}
diff --git a/src/Doctrine/Extension/ProjectAccessExtension.php b/src/Doctrine/Extension/ProjectAccessExtension.php
new file mode 100644
index 0000000..3e32936
--- /dev/null
+++ b/src/Doctrine/Extension/ProjectAccessExtension.php
@@ -0,0 +1,44 @@
+security->getUser();
+ if (!$user instanceof User) {
+ return;
+ }
+
+ $rootAlias = $queryBuilder->getRootAliases()[0];
+ $teamMemberAlias = $queryNameGenerator->generateJoinAlias('teamMembers');
+
+ $queryBuilder
+ ->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias)
+ ->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias))
+ ->setParameter('current_user', $user);
+ }
+}
diff --git a/src/Entity/Project.php b/src/Entity/Project.php
index 4980508..35cabe9 100644
--- a/src/Entity/Project.php
+++ b/src/Entity/Project.php
@@ -132,10 +132,22 @@ class Project implements ModuleAwareInterface
#[Groups(['project:read'])]
private Collection $gitRepositories;
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ #[Groups(['project:read', 'project:write'])]
+ #[Assert\NotNull(message: 'Ein Projekt muss einen Eigentümer haben')]
+ private ?User $owner = null;
+
+ #[ORM\ManyToMany(targetEntity: User::class)]
+ #[ORM\JoinTable(name: 'project_team_members')]
+ #[Groups(['project:read', 'project:write'])]
+ private Collection $teamMembers;
+
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->gitRepositories = new ArrayCollection();
+ $this->teamMembers = new ArrayCollection();
}
public function getId(): ?int
@@ -344,6 +356,52 @@ class Project implements ModuleAwareInterface
return $this;
}
+ public function getOwner(): ?User
+ {
+ return $this->owner;
+ }
+
+ public function setOwner(?User $owner): static
+ {
+ $this->owner = $owner;
+ $this->updatedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getTeamMembers(): Collection
+ {
+ return $this->teamMembers;
+ }
+
+ public function addTeamMember(User $teamMember): static
+ {
+ if (!$this->teamMembers->contains($teamMember)) {
+ $this->teamMembers->add($teamMember);
+ }
+
+ return $this;
+ }
+
+ public function removeTeamMember(User $teamMember): static
+ {
+ $this->teamMembers->removeElement($teamMember);
+
+ return $this;
+ }
+
+ public function isTeamMember(User $user): bool
+ {
+ return $this->teamMembers->contains($user);
+ }
+
+ public function hasAccess(User $user): bool
+ {
+ return $this->owner === $user || $this->isTeamMember($user);
+ }
+
/**
* Returns the module code this entity belongs to.
* Required by ModuleVoter for permission checks.
diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php
index 2e1919e..fb4f47e 100644
--- a/src/Repository/ProjectRepository.php
+++ b/src/Repository/ProjectRepository.php
@@ -54,4 +54,19 @@ class ProjectRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
+
+ /**
+ * Find all projects where user is owner or team member
+ */
+ public function findUserProjects(\App\Entity\User $user): array
+ {
+ return $this->createQueryBuilder('p')
+ ->leftJoin('p.teamMembers', 'tm')
+ ->where('p.owner = :user')
+ ->orWhere('tm = :user')
+ ->setParameter('user', $user)
+ ->orderBy('p.startDate', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Security/Voter/GitRepositoryVoter.php b/src/Security/Voter/GitRepositoryVoter.php
index 6e6cde7..e478038 100644
--- a/src/Security/Voter/GitRepositoryVoter.php
+++ b/src/Security/Voter/GitRepositoryVoter.php
@@ -57,14 +57,8 @@ class GitRepositoryVoter extends Voter
return false;
}
- // TODO: Implement proper project-level permissions when Project has user relationships
- // For now, allow all authenticated users to view repositories in existing projects
- // This is safe because:
- // 1. SearchFilter on 'project' ensures users can only query their accessible projects
- // 2. Direct item access requires knowing the project exists
- // 3. Future ProjectVoter will add fine-grained control
-
- return true;
+ // Allow access if user is owner or team member
+ return $project->hasAccess($user);
}
private function canEdit(GitRepository $gitRepository, User $user): bool
@@ -76,11 +70,8 @@ class GitRepositoryVoter extends Voter
return false;
}
- // TODO: Implement role-based permissions when Project entity has user relationships
- // For now, allow all authenticated users to edit repositories
- // This maintains current behavior while adding structure for future restrictions
-
- return true;
+ // Allow edit if user is owner or team member
+ return $project->hasAccess($user);
}
private function canDelete(GitRepository $gitRepository, User $user): bool
@@ -92,10 +83,7 @@ class GitRepositoryVoter extends Voter
return false;
}
- // TODO: Restrict to project owners when Project entity has owner relationship
- // For now, allow all authenticated users to delete repositories
- // This maintains current behavior while adding structure for future restrictions
-
- return true;
+ // Only project owner can delete repositories
+ return $project->getOwner() === $user;
}
}
diff --git a/src/Security/Voter/ProjectVoter.php b/src/Security/Voter/ProjectVoter.php
new file mode 100644
index 0000000..88613ee
--- /dev/null
+++ b/src/Security/Voter/ProjectVoter.php
@@ -0,0 +1,73 @@
+getUser();
+
+ if (!$user instanceof User) {
+ return false;
+ }
+
+ // CREATE permission - any authenticated user can create projects
+ if ($attribute === self::CREATE) {
+ return true;
+ }
+
+ /** @var Project $project */
+ $project = $subject;
+
+ return match ($attribute) {
+ self::VIEW => $this->canView($project, $user),
+ self::EDIT => $this->canEdit($project, $user),
+ self::DELETE => $this->canDelete($project, $user),
+ default => false,
+ };
+ }
+
+ private function canView(Project $project, User $user): bool
+ {
+ // Owner and team members can view
+ return $project->hasAccess($user);
+ }
+
+ private function canEdit(Project $project, User $user): bool
+ {
+ // Owner and team members can edit
+ return $project->hasAccess($user);
+ }
+
+ private function canDelete(Project $project, User $user): bool
+ {
+ // Only owner can delete
+ return $project->getOwner() === $user;
+ }
+}
diff --git a/src/State/ProjectCollectionProvider.php b/src/State/ProjectCollectionProvider.php
new file mode 100644
index 0000000..a1a2efc
--- /dev/null
+++ b/src/State/ProjectCollectionProvider.php
@@ -0,0 +1,31 @@
+security->getUser();
+
+ if (!$user instanceof User) {
+ return [];
+ }
+
+ // Get all projects where user is owner or team member
+ return $this->projectRepository->findUserProjects($user);
+ }
+}