Skip to content

Commit dddc05f

Browse files
[WIP] Meeting Proposals
Signed-off-by: SebastianKrupinski <[email protected]>
1 parent 97959df commit dddc05f

54 files changed

Lines changed: 6590 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* 📎 **Attachments!** Add, upload and view event attachments
2424
* 🙈 **We’re not reinventing the wheel!** Based on the great [c-dav library](https://github.com/nextcloud/cdav-library), [ical.js](https://github.com/mozilla-comm/ical.js) and [fullcalendar](https://github.com/fullcalendar/fullcalendar) libraries.
2525
]]></description>
26-
<version>5.6.0-dev.1</version>
26+
<version>5.6.0-dev.2</version>
2727
<licence>agpl</licence>
2828
<author homepage="https://github.com/st3iny">Richard Steinmetz</author>
2929
<author homepage="https://github.com/SebastianKrupinski">Sebastian Krupinski </author>
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Controller;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\Calendar\Objects\Proposal\ProposalObject;
14+
use OCA\Calendar\Objects\Proposal\ProposalResponseObject;
15+
use OCA\Calendar\Service\Proposal\ProposalService;
16+
use OCP\AppFramework\ApiController;
17+
use OCP\AppFramework\Http;
18+
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
19+
use OCP\AppFramework\Http\Attribute\ApiRoute;
20+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
21+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
22+
use OCP\AppFramework\Http\Attribute\PublicPage;
23+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
24+
use OCP\AppFramework\Http\JSONResponse;
25+
use OCP\IGroupManager;
26+
use OCP\IRequest;
27+
use OCP\IUser;
28+
use OCP\IUserManager;
29+
use OCP\IUserSession;
30+
31+
class ProposalController extends ApiController {
32+
33+
public function __construct(
34+
IRequest $request,
35+
private IUserSession $userSession,
36+
private IUserManager $userManager,
37+
private IGroupManager $groupManager,
38+
private ProposalService $proposalService,
39+
) {
40+
parent::__construct(Application::APP_ID, $request);
41+
}
42+
43+
private function authorize(?string $userId = null): JSONResponse|IUser {
44+
// evaluate if user is logged in and has permissions
45+
if (!$this->userSession->isLoggedIn()) {
46+
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
47+
}
48+
if ($userId !== null) {
49+
if ($this->userSession->getUser()->getUID() !== $userId
50+
&& $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) {
51+
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
52+
}
53+
if (!$this->userManager->userExists($userId)) {
54+
return new JSONResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST);
55+
}
56+
$user = $this->userManager->get($userId);
57+
} else {
58+
$user = $this->userSession->getUser();
59+
}
60+
if ($user === null) {
61+
return new JSONResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST);
62+
}
63+
return $user;
64+
}
65+
66+
/**
67+
* Retrieve list of available proposals
68+
*/
69+
#[ApiRoute(verb: 'POST', url: '/proposal/list', root: '/calendar')]
70+
#[NoAdminRequired]
71+
#[UserRateLimit(limit: 10, period: 60)]
72+
public function list(?string $user = null): JSONResponse {
73+
// authorize request
74+
$authorization = $this->authorize($user);
75+
if ($authorization instanceof JSONResponse) {
76+
return $authorization;
77+
}
78+
$userObject = $authorization;
79+
// retrieve proposals for the user
80+
$proposals = $this->proposalService->listProposals($userObject);
81+
82+
return new JSONResponse($proposals, Http::STATUS_OK);
83+
}
84+
85+
/**
86+
* Fetch a proposal by its token
87+
*/
88+
#[ApiRoute(verb: 'POST', url: '/proposal/fetch', root: '/calendar')]
89+
#[PublicPage]
90+
#[NoCSRFRequired]
91+
#[NoAdminRequired]
92+
#[AnonRateLimit(limit: 10, period: 300)]
93+
#[UserRateLimit(limit: 10, period: 300)]
94+
public function fetchByToken(string $token): JSONResponse {
95+
$proposal = $this->proposalService->fetchByToken($token);
96+
if ($proposal === null) {
97+
return new JSONResponse(['error' => 'Proposal not found'], HTTP::STATUS_NOT_FOUND);
98+
}
99+
// enrich proposal with user information
100+
// as this is most likely a public request from the voting page
101+
$user = $this->userManager->get($proposal->getUid());
102+
if ($user !== null) {
103+
$proposal->setUname($user->getDisplayName());
104+
}
105+
return new JSONResponse($proposal, Http::STATUS_OK);
106+
}
107+
108+
/**
109+
* Create a new proposal
110+
*/
111+
#[ApiRoute(verb: 'POST', url: '/proposal/create', root: '/calendar')]
112+
#[NoAdminRequired]
113+
#[UserRateLimit(limit: 10, period: 60)]
114+
public function create(array $proposal, ?string $user = null): JSONResponse {
115+
// authorize request
116+
$authorization = $this->authorize($user);
117+
if ($authorization instanceof JSONResponse) {
118+
return $authorization;
119+
}
120+
$userObject = $authorization;
121+
// construct proposal object from the provided data
122+
$proposalObject = new ProposalObject();
123+
$proposalObject->fromJson($proposal);
124+
// handle the creation of the proposal
125+
$proposalObject = $this->proposalService->createProposal($userObject, $proposalObject);
126+
127+
return new JSONResponse($proposalObject, Http::STATUS_OK);
128+
}
129+
130+
/**
131+
* Modify a proposal
132+
*/
133+
#[ApiRoute(verb: 'POST', url: '/proposal/modify', root: '/calendar')]
134+
#[NoAdminRequired]
135+
#[UserRateLimit(limit: 10, period: 60)]
136+
public function modify(array $proposal, ?string $user = null): JSONResponse {
137+
// authorize request
138+
$authorization = $this->authorize($user);
139+
if ($authorization instanceof JSONResponse) {
140+
return $authorization;
141+
}
142+
$userObject = $authorization;
143+
// construct proposal object from the provided data
144+
$proposalObject = new ProposalObject();
145+
$proposalObject->fromJson($proposal);
146+
// handle the modification of the proposal
147+
try {
148+
$proposalObject = $this->proposalService->modifyProposal($userObject, $proposalObject);
149+
} catch (\InvalidArgumentException $e) {
150+
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND);
151+
}
152+
153+
return new JSONResponse($proposalObject, Http::STATUS_OK);
154+
}
155+
156+
/**
157+
* Destroy a proposal
158+
*/
159+
#[ApiRoute(verb: 'POST', url: '/proposal/destroy', root: '/calendar')]
160+
#[NoAdminRequired]
161+
#[UserRateLimit(limit: 10, period: 60)]
162+
public function destroy(int $id, ?string $user = null): JSONResponse {
163+
// authorize request
164+
$authorization = $this->authorize($user);
165+
if ($authorization instanceof JSONResponse) {
166+
return $authorization;
167+
}
168+
$userObject = $authorization;
169+
// handle the destruction of the proposal
170+
try {
171+
$this->proposalService->destroyProposal($userObject, $id);
172+
} catch (\InvalidArgumentException $e) {
173+
return new JSONResponse(['error' => 'Proposal not found'], Http::STATUS_NOT_FOUND);
174+
}
175+
176+
return new JSONResponse([], Http::STATUS_OK);
177+
}
178+
179+
/**
180+
* Convert a proposed date to a meeting
181+
*/
182+
#[ApiRoute(verb: 'POST', url: '/proposal/convert', root: '/calendar')]
183+
#[NoAdminRequired]
184+
#[UserRateLimit(limit: 10, period: 60)]
185+
public function convert(int $proposalId, int $dateId, ?string $user = null): JSONResponse {
186+
// authorize request
187+
$authorization = $this->authorize($user);
188+
if ($authorization instanceof JSONResponse) {
189+
return $authorization;
190+
}
191+
$userObject = $authorization;
192+
// handle the conversion
193+
try {
194+
$this->proposalService->convertProposal($userObject, $proposalId, $dateId);
195+
} catch (\InvalidArgumentException $e) {
196+
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND);
197+
}
198+
199+
return new JSONResponse([], Http::STATUS_OK);
200+
}
201+
202+
/**
203+
* Public view proposal response/vote
204+
*/
205+
#[ApiRoute(verb: 'POST', url: '/proposal/response', root: '/calendar')]
206+
#[PublicPage]
207+
#[NoCSRFRequired]
208+
#[NoAdminRequired]
209+
#[AnonRateLimit(limit: 10, period: 300)]
210+
#[UserRateLimit(limit: 10, period: 300)]
211+
public function response(array $response): JSONResponse {
212+
try {
213+
// construct proposal response object from the provided data
214+
$proposalResponse = new ProposalResponseObject();
215+
$proposalResponse->fromJson($response);
216+
// handle the response
217+
$this->proposalService->storeResponse($proposalResponse);
218+
return new JSONResponse([], Http::STATUS_OK);
219+
} catch (\Exception $e) {
220+
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
221+
}
222+
}
223+
224+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Controller;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\Calendar\Service\Proposal\ProposalService;
14+
use OCP\AppFramework\Controller;
15+
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
16+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
17+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
18+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
19+
use OCP\AppFramework\Http\Attribute\PublicPage;
20+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
21+
use OCP\AppFramework\Http\Response;
22+
use OCP\AppFramework\Http\TemplateResponse;
23+
use OCP\IRequest;
24+
25+
class ProposalPublicController extends Controller {
26+
27+
public function __construct(
28+
IRequest $request,
29+
private ProposalService $proposalService,
30+
) {
31+
parent::__construct(Application::APP_ID, $request);
32+
}
33+
34+
#[FrontpageRoute(verb: 'GET', url: '/proposal/{token}', root: '/calendar')]
35+
#[PublicPage]
36+
#[NoCSRFRequired]
37+
#[NoAdminRequired]
38+
#[AnonRateLimit(limit: 10, period: 300)]
39+
#[UserRateLimit(limit: 10, period: 300)]
40+
public function index(string $token): Response {
41+
\OCP\Util::addScript(Application::APP_ID, Application::APP_ID . '-proposal-public');
42+
43+
return new TemplateResponse(Application::APP_ID, 'public', [], TemplateResponse::RENDER_AS_PUBLIC);
44+
}
45+
}

lib/Db/ProposalDateEntry.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\Calendar\Db;
9+
10+
use OCP\AppFramework\Db\Entity;
11+
12+
/**
13+
* @method int getId()
14+
* @method void setId(int $value)
15+
* @method ?string getUid()
16+
* @method void setUid(string $value)
17+
* @method ?int getPid()
18+
* @method void setPid(int $value)
19+
* @method ?int getDate()
20+
* @method void setDate(int $value)
21+
*/
22+
class ProposalDateEntry extends Entity {
23+
protected ?string $uid = null;
24+
protected ?int $pid = null;
25+
protected ?int $date = null;
26+
}

lib/Db/ProposalDateMapper.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Db;
11+
12+
use OCP\AppFramework\Db\QBMapper;
13+
use OCP\DB\QueryBuilder\IQueryBuilder;
14+
use OCP\IDBConnection;
15+
16+
/**
17+
* @template-extends QBMapper<ProposalDateEntry>
18+
*/
19+
class ProposalDateMapper extends QBMapper {
20+
21+
public function __construct(
22+
IDBConnection $db,
23+
) {
24+
$this->tableName = 'calendar_proposal_dats';
25+
parent::__construct($db, $this->tableName, ProposalDateEntry::class);
26+
}
27+
28+
public function fetchByProposalId(string $userId, int $proposalId): array {
29+
$qb = $this->db->getQueryBuilder();
30+
$qb->select('*')
31+
->from($this->tableName)
32+
->where(
33+
$qb->expr()->eq('uid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)),
34+
$qb->expr()->eq('pid', $qb->createNamedParameter($proposalId, IQueryBuilder::PARAM_INT))
35+
);
36+
return $this->findEntities($qb);
37+
}
38+
39+
public function fetchByUserId(string $userId): array {
40+
$qb = $this->db->getQueryBuilder();
41+
$qb->select('*')
42+
->from($this->tableName)
43+
->where(
44+
$qb->expr()->eq('uid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
45+
);
46+
return $this->findEntities($qb);
47+
}
48+
49+
public function deleteById(string $userId, int $id): void {
50+
$qb = $this->db->getQueryBuilder();
51+
$qb->delete($this->tableName)
52+
->where(
53+
$qb->expr()->eq('uid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)),
54+
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
55+
);
56+
$qb->executeStatement();
57+
}
58+
59+
public function deleteByProposalId(string $userId, int $proposalId): void {
60+
$qb = $this->db->getQueryBuilder();
61+
$qb->delete($this->tableName)
62+
->where(
63+
$qb->expr()->eq('uid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)),
64+
$qb->expr()->eq('pid', $qb->createNamedParameter($proposalId, IQueryBuilder::PARAM_INT))
65+
);
66+
$qb->executeStatement();
67+
}
68+
69+
public function deleteByUserId(string $userId): void {
70+
$qb = $this->db->getQueryBuilder();
71+
$qb->delete($this->tableName)
72+
->where(
73+
$qb->expr()->eq('uid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
74+
);
75+
$qb->executeStatement();
76+
}
77+
78+
}

0 commit comments

Comments
 (0)