@tiledesk/tiledesk-server 2.17.3 โ†’ 2.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/app.js +2 -0
  3. package/archive.sh +92 -0
  4. package/channels/chat21/chat21WebHook.js +6 -1
  5. package/event/authEvent.js +16 -0
  6. package/event/projectUserEvent.js +39 -0
  7. package/event/roleEvent.js +9 -0
  8. package/middleware/has-role.js +160 -121
  9. package/middleware/passport.js +180 -179
  10. package/migrations/1757601159298-project_user_role_type.js +45 -0
  11. package/models/department.js +3 -0
  12. package/models/groupMemberSchama.js +19 -0
  13. package/models/kb_setting.js +6 -2
  14. package/models/permissionConstants.js +19 -0
  15. package/models/project_user.js +86 -8
  16. package/models/request.js +1 -0
  17. package/models/role.js +31 -0
  18. package/models/roleConstants.js +2 -0
  19. package/package.json +1 -1
  20. package/pubmodules/analytics/analytics.js +2 -2
  21. package/pubmodules/cache/mongoose-cachegoose-fn.js +37 -0
  22. package/pubmodules/canned/cannedResponseRoute.js +34 -6
  23. package/pubmodules/routing-queue/listener.js +7 -1
  24. package/pubmodules/trigger/rulesTrigger.js +1 -6
  25. package/routes/auth.js +3 -1
  26. package/routes/department.js +7 -1
  27. package/routes/kb.js +25 -1
  28. package/routes/message.js +4 -1
  29. package/routes/project.js +41 -3
  30. package/routes/project_user.js +62 -11
  31. package/routes/request.js +32 -30
  32. package/routes/roles.js +151 -0
  33. package/routes/unanswered.js +1 -1
  34. package/routes/webhook.js +18 -13
  35. package/routes/widget.js +3 -1
  36. package/services/cacheEnabler.js +5 -8
  37. package/services/departmentService.js +39 -11
  38. package/services/emailService.js +2 -2
  39. package/services/pendingInvitationService.js +2 -0
  40. package/services/projectService.js +3 -1
  41. package/services/projectUserService.js +67 -4
  42. package/services/subscriptionNotifierQueued.js +8 -0
  43. package/services/updateRequestSnapshotQueued.js +0 -3
  44. package/test/departmentService.js +5 -0
  45. package/test/messageRoute.js +7 -4
  46. package/test/projectUserRoute.js +116 -0
  47. package/test/requestService.js +7 -3
  48. package/test-int/bot.js +3 -2
  49. package/websocket/webSocketServer.js +273 -225
  50. package/routes/auth_newjwt.js +0 -648
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
5
5
  ๐Ÿš€ IN PRODUCTION ๐Ÿš€
6
6
  (https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
7
7
 
8
+ # 2.18.1
9
+ - Added permissions logic
10
+ - Added custom roles support
11
+
12
+ # 2.17.4
13
+ - Refactor error handling and code structure in webhook.js
14
+
8
15
  # 2.17.3
9
16
  - Added missing import path on kb route
10
17
 
package/app.js CHANGED
@@ -149,6 +149,7 @@ var property = require('./routes/property');
149
149
  var segment = require('./routes/segment');
150
150
  var webhook = require('./routes/webhook');
151
151
  var webhooks = require('./routes/webhooks');
152
+ var roles = require('./routes/roles');
152
153
  var copilot = require('./routes/copilot');
153
154
  var mcp = require('./routes/mcp');
154
155
 
@@ -648,6 +649,7 @@ app.use('/:projectid/logs', [passport.authenticate(['basic', 'jwt'], { session:
648
649
 
649
650
  app.use('/:projectid/webhooks', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRole('admin')], webhooks);
650
651
  app.use('/:projectid/copilot', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRole('agent')], copilot);
652
+ app.use('/:projectid/roles', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRole('agent')], roles);
651
653
 
652
654
  app.use('/:projectid/files', filesp);
653
655
 
package/archive.sh ADDED
@@ -0,0 +1,92 @@
1
+ #!/bin/bash
2
+
3
+ # ================================
4
+ # Script interattivo per archiviare branch
5
+ # ================================
6
+ # Usage: ./archive-branch.sh [mode]
7
+ # mode: tag (default) | rename
8
+ # ================================
9
+
10
+ REMOTE="tiledesk-server"
11
+ MODE="${1:-tag}" # default mode = tag
12
+
13
+ PROTECTED_BRANCHES=("master" "master-PRE" "master-COLLAUDO" "master-STAGE")
14
+
15
+ # Funzione per scegliere un branch valido
16
+ get_branch_to_archive() {
17
+ local branch="$1"
18
+
19
+ while true; do
20
+ # Se branch protetto, avvisa
21
+ if [[ " ${PROTECTED_BRANCHES[@]} " =~ " ${branch} " ]]; then
22
+ echo "โŒ Il branch '$branch' รจ protetto e non puรฒ essere archiviato."
23
+ fi
24
+
25
+ # Controlla che il branch locale esista
26
+ if git show-ref --verify --quiet refs/heads/"$branch"; then
27
+ break # branch valido trovato
28
+ fi
29
+
30
+ # Richiedi input allโ€™utente
31
+ read -p "Inserisci un branch locale valido da archiviare (oppure 'quit' per annullare): " branch
32
+ if [[ "$branch" == "quit" || "$branch" == "q" ]]; then
33
+ echo "Operazione annullata."
34
+ exit 0
35
+ fi
36
+ done
37
+
38
+ echo "$branch"
39
+ }
40
+
41
+ # Branch corrente
42
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
43
+
44
+ # Se branch corrente non รจ protetto, chiedi conferma
45
+ if [[ ! " ${PROTECTED_BRANCHES[@]} " =~ " ${CURRENT_BRANCH} " ]]; then
46
+ echo "โšก Branch corrente: $CURRENT_BRANCH"
47
+ read -p "Vuoi archiviare questo branch? (y/n): " CONFIRM
48
+ if [[ "$CONFIRM" != "y" ]]; then
49
+ read -p "Inserisci il branch da archiviare (oppure 'quit' per annullare): " USER_BRANCH
50
+ if [[ "$USER_BRANCH" == "quit" || "$USER_BRANCH" == "q" ]]; then
51
+ echo "Operazione annullata."
52
+ exit 0
53
+ fi
54
+ CURRENT_BRANCH=$(get_branch_to_archive "$USER_BRANCH")
55
+ fi
56
+ else
57
+ # Branch corrente รจ protetto, chiedi un branch alternativo
58
+ CURRENT_BRANCH=$(get_branch_to_archive "$CURRENT_BRANCH")
59
+ fi
60
+
61
+ # Operazione di archiviazione
62
+ if [ "$MODE" == "tag" ]; then
63
+ ARCHIVE_TAG="archive/$(echo $CURRENT_BRANCH | tr '/' '-')"
64
+ echo "Creando tag di archivio: $ARCHIVE_TAG"
65
+ git tag "$ARCHIVE_TAG" "$CURRENT_BRANCH"
66
+ git push "$REMOTE" "$ARCHIVE_TAG"
67
+
68
+ echo "Eliminando branch remoto $CURRENT_BRANCH"
69
+ git push "$REMOTE" --delete "$CURRENT_BRANCH"
70
+
71
+ echo "Eliminando branch locale $CURRENT_BRANCH"
72
+ git branch -d "$CURRENT_BRANCH"
73
+
74
+ echo "โœ… Branch '$CURRENT_BRANCH' archiviato con tag '$ARCHIVE_TAG'"
75
+
76
+ elif [ "$MODE" == "rename" ]; then
77
+ ARCHIVE_BRANCH="archive/$CURRENT_BRANCH"
78
+ echo "Rinomino branch locale in: $ARCHIVE_BRANCH"
79
+ git branch -m "$CURRENT_BRANCH" "$ARCHIVE_BRANCH"
80
+
81
+ echo "Pusho nuovo branch su remote $REMOTE"
82
+ git push "$REMOTE" "$ARCHIVE_BRANCH"
83
+
84
+ echo "Eliminando branch remoto vecchio $CURRENT_BRANCH"
85
+ git push "$REMOTE" --delete "$CURRENT_BRANCH"
86
+
87
+ echo "โœ… Branch '$CURRENT_BRANCH' archiviato come '$ARCHIVE_BRANCH'"
88
+
89
+ else
90
+ echo "โŒ Modalitร  non valida. Usa 'tag' o 'rename'."
91
+ exit 1
92
+ fi
@@ -307,7 +307,12 @@ router.post('/', function (req, res) {
307
307
  // TODO se stato = 50 e scrive visitatotre sposto a stato 100 poi queuue lo smista
308
308
 
309
309
  // TOOD update also request attributes and sourcePage
310
-
310
+ if (message.sender !== 'system') {
311
+ Request.findOneAndUpdate({request_id: request.request_id, id_project: request.id_project}, { "attributes.last_message": savedMessage}).catch((err) => {
312
+ winston.error("Create message - saving last message in request error: ", err);
313
+ })
314
+ }
315
+
311
316
  // return requestService.incrementMessagesCountByRequestId(request.request_id, request.id_project).then(function(savedRequest) {
312
317
  // winston.debug("savedRequest.participants.indexOf(message.sender)", savedRequest.participants.indexOf(message.sender));
313
318
  winston.debug("before updateWaitingTimeByRequestId*******",request.participants, message.sender);
@@ -1,4 +1,5 @@
1
1
  const EventEmitter = require('events');
2
+ var RoleConstants = require("../models/roleConstants");
2
3
 
3
4
  class AuthEvent extends EventEmitter {
4
5
  constructor() {
@@ -9,6 +10,21 @@ class AuthEvent extends EventEmitter {
9
10
 
10
11
  const authEvent = new AuthEvent();
11
12
 
13
+ var projectuserUpdateKey = 'project_user.update';
14
+ if (process.env.QUEUE_ENABLED === "true") {
15
+ projectuserUpdateKey = 'project_user.update.queue';
16
+ }
17
+
18
+ authEvent.on(projectuserUpdateKey, function(event) {
19
+ if (event.updatedProject_userPopulated) {
20
+ var pu = event.updatedProject_userPopulated;
21
+ if (pu.roleType === RoleConstants.TYPE_AGENTS) {
22
+ authEvent.emit("project_user.update.agent", event);
23
+ } else {
24
+ authEvent.emit("project_user.update.user", event);
25
+ }
26
+ }
27
+ });
12
28
 
13
29
  //listen for sigin and signup event
14
30
 
@@ -0,0 +1,39 @@
1
+ const EventEmitter = require('events');
2
+ const winston = require('../config/winston');
3
+ const Group = require("../models/group");
4
+
5
+ class ProjectUserEvent extends EventEmitter {
6
+ constructor() {
7
+ super();
8
+ this.registerListeners();
9
+ }
10
+
11
+ registerListeners() {
12
+
13
+ this.on('project_user.deleted', async (pu) => {
14
+
15
+ try {
16
+ winston.debug('[project_user.deleted] Event catched:', pu);
17
+
18
+ const id_project = pu.id_project;
19
+ let id_user = pu.id_user.toString();
20
+ if (typeof id_user === 'object' && id_user._id) {
21
+ id_user = id_user._id.toString();
22
+ }
23
+
24
+ const result = await Group.updateMany({ id_project: id_project }, { $pull: { members: id_user }});
25
+ winston.verbose(`Event project_user.deleted: User ${id_user} removed from ${result?.nModified} groups.`)
26
+
27
+ } catch (err) {
28
+ winston.verbose(`Event project_user.deleted: Error removing user ${id_user} from groups: `, err);
29
+ }
30
+
31
+ });
32
+
33
+ }
34
+ }
35
+
36
+ // Istanza singleton
37
+ const puEvent = new ProjectUserEvent();
38
+
39
+ module.exports = puEvent;
@@ -0,0 +1,9 @@
1
+ const EventEmitter = require('events');
2
+
3
+ class RoleEvent extends EventEmitter {}
4
+
5
+ const roleEvent = new RoleEvent();
6
+
7
+
8
+
9
+ module.exports = roleEvent;
@@ -1,18 +1,16 @@
1
- var Project_user = require("../models/project_user");
2
1
  var Faq_kb = require("../models/faq_kb");
3
2
  var Subscription = require("../models/subscription");
4
3
  var winston = require('../config/winston');
5
4
 
6
- var cacheUtil = require('../utils/cacheUtil');
7
- var cacheEnabler = require("../services/cacheEnabler");
8
-
5
+ var projectUserService = require("../services/projectUserService");
9
6
  class RoleChecker {
10
7
 
11
8
 
12
9
 
13
10
  constructor() {
11
+ winston.debug("RoleChecker");
14
12
 
15
- this.ROLES = {
13
+ this.ROLES = {
16
14
  "guest": ["guest"],
17
15
  "user": ["guest","user"],
18
16
  "teammate": ["guest","user","teammate"],
@@ -20,7 +18,7 @@ class RoleChecker {
20
18
  "supervisor": ["guest","user","teammate","agent","supervisor"],
21
19
  "admin": ["guest","user","teammate","agent", "supervisor", "admin"],
22
20
  "owner": ["guest","user","teammate","agent", "supervisor", "admin", "owner"],
23
- }
21
+ }
24
22
  }
25
23
 
26
24
  isType(type) {
@@ -87,18 +85,90 @@ class RoleChecker {
87
85
  }
88
86
 
89
87
 
88
+ hasPermission(permission) {
89
+ var that = this;
90
+ return async(req, res, next) => {
91
+ if (!req.params.projectid && !req.projectid) {
92
+ return res.status(400).send({success: false, msg: 'req.params.projectid is not defined.'});
93
+ }
94
+
95
+ let projectid = req.params.projectid || req.projectid;
96
+
97
+ var project_user = req.projectuser;
98
+ winston.debug("hasPermission project_user hasPermission", project_user);
99
+
100
+ if (!project_user) {
101
+
102
+ try {
103
+ project_user = await projectUserService.getWithPermissions(req.user._id, projectid, req.user.sub);
104
+ } catch(err) {
105
+ winston.error("Error getting project_user for hasrole",err);
106
+ return next(err);
107
+ }
108
+ winston.debug("project_user: ", JSON.stringify(project_user));
109
+ }
110
+
111
+ if (project_user) {
112
+
113
+ req.projectuser = project_user;
114
+ winston.debug("hasPermission req.projectuser", req.projectuser);
115
+
116
+ var permissions = project_user._doc.rolePermissions;
117
+
118
+ winston.debug("hasPermission permissions", permissions);
119
+
120
+ winston.debug("hasPermission permission: "+ permission);
121
+
122
+ if (permission==undefined) {
123
+ winston.debug("permission is empty go next");
124
+ return next();
125
+ }
126
+
127
+ if (permissions!=undefined && permissions.length>0) {
128
+ if (permissions.includes(permission)) {
129
+ next();
130
+ }else {
131
+ res.status(403).send({success: false, msg: 'you dont have the required permission.'});
132
+ }
133
+ } else {
134
+ res.status(403).send({success: false, msg: 'you dont have the required permission. Is is empty'});
135
+ }
136
+ } else {
137
+
138
+ /**
139
+ * Updated by Johnny - 29mar2024 - START
140
+ */
141
+ // console.log("req.user: ", req.user);
142
+ if (req.user.email === process.env.ADMIN_EMAIL) {
143
+ req.user.attributes = { isSuperadmin: true };
144
+ next();
145
+ } else {
146
+ res.status(403).send({success: false, msg: 'you dont belong to the project.'});
147
+ }
148
+ /**
149
+ * Updated by Johnny - 29mar2024 - END
150
+ */
151
+
152
+ }
153
+
154
+
155
+
156
+
157
+ }
158
+ }
90
159
 
91
160
 
92
161
  hasRole(role) {
93
162
  return this.hasRoleOrTypes(role);
94
163
  }
95
164
 
96
- hasRoleOrTypes(role, types) {
97
-
165
+ hasRoleOrTypes(role, types, permission) {
166
+ // console.log("hasRoleOrTypes",role,types);
167
+
98
168
  var that = this;
99
169
 
100
170
  // winston.debug("HasRole");
101
- return function(req, res, next) {
171
+ return async(req, res, next) => {
102
172
 
103
173
  // winston.debug("req.originalUrl" + req.originalUrl);
104
174
  // winston.debug("req.params" + JSON.stringify(req.params));
@@ -111,7 +181,7 @@ class RoleChecker {
111
181
 
112
182
  let projectid = req.params.projectid || req.projectid;
113
183
 
114
- // winston.info("req.user._id: " + req.user._id);
184
+ // winston.info("req.user._id: " + req.user._id);
115
185
 
116
186
  // winston.info("req.projectuser: " + req.projectuser);
117
187
  //winston.debug("req.user", req.user);
@@ -135,138 +205,107 @@ class RoleChecker {
135
205
  // res.status(403).send({success: false, msg: 'req.user._id not defined.'});
136
206
  // }
137
207
  winston.debug("hasRoleOrType req.user._id " +req.user._id);
138
- // project_user_qui_importante
139
-
140
- // JWT_HERE
141
- var query = { id_project: projectid, id_user: req.user._id, status: "active"};
142
- let cache_key = projectid+":project_users:iduser:"+req.user._id
143
-
144
- if (req.user.sub && (req.user.sub=="userexternal" || req.user.sub=="guest")) {
145
- query = { id_project: projectid, uuid_user: req.user._id, status: "active"};
146
- cache_key = projectid+":project_users:uuid_user:"+req.user._id
208
+ // project_user_qui_importante
209
+
210
+ var project_user = req.projectuser;
211
+ winston.debug("hasRoleOrTypes project_user hasRoleOrTypes", project_user);
212
+
213
+ if (!project_user) {
214
+ winston.debug("load project_user");
215
+ try {
216
+ project_user = await projectUserService.getWithPermissions(req.user._id, projectid, req.user.sub);
217
+ } catch(err) {
218
+ winston.error("Error getting project_user for hasrole",err);
219
+ return next(err);
220
+ }
221
+
222
+ winston.debug("project_user: ", JSON.stringify(project_user));
223
+
147
224
  }
148
- winston.debug("hasRoleOrType query " + JSON.stringify(query));
149
225
 
150
- let q = Project_user.findOne(query);
151
- if (cacheEnabler.project_user) {
152
- q.cache(cacheUtil.defaultTTL, cache_key);
153
- winston.debug("cacheEnabler.project_user enabled");
226
+ if (project_user) {
227
+
228
+ req.projectuser = project_user;
229
+ winston.debug("hasRoleOrTypes req.projectuser", req.projectuser.toJSON());
154
230
 
155
- }
156
- q.exec(function (err, project_user) {
157
- if (err) {
158
- winston.error("Error on Request path: " + req.originalUrl);
159
- winston.error("Error getting project_user for hasrole",err);
160
- return next(err);
161
- }
162
- winston.debug("project_user: ", JSON.stringify(project_user));
163
-
164
-
165
-
166
- if (project_user) {
167
-
168
- req.projectuser = project_user;
169
- winston.debug("req.projectuser", req.projectuser);
231
+ var userRole = project_user.role;
232
+ winston.debug("userRole", userRole);
170
233
 
171
- var userRole = project_user.role;
172
- winston.debug("userRole", userRole);
173
-
174
- if (!role) {
175
- next();
176
- }else {
177
-
178
- var hierarchicalRoles = that.ROLES[userRole];
179
- winston.debug("hierarchicalRoles", hierarchicalRoles);
180
-
181
- if ( hierarchicalRoles && hierarchicalRoles.includes(role)) {
182
- next();
183
- }else {
184
- res.status(403).send({success: false, msg: 'you dont have the required role.'});
185
- }
186
- }
187
- } else {
188
-
189
- /**
190
- * Updated by Johnny - 29mar2024 - START
191
- */
192
- // console.log("req.user: ", req.user);
193
- if (req.user.email === process.env.ADMIN_EMAIL) {
194
- req.user.attributes = { isSuperadmin: true };
234
+
235
+ if (!role) { //????
236
+ next();
237
+ }else {
238
+
239
+ var hierarchicalRoles = that.ROLES[userRole];
240
+ winston.debug("hierarchicalRoles", hierarchicalRoles);
241
+
242
+ if ( hierarchicalRoles ) { //standard role
243
+ winston.debug("standard role: "+ userRole);
244
+ if (hierarchicalRoles.includes(role)) {
195
245
  next();
196
246
  } else {
197
- res.status(403).send({success: false, msg: 'you dont belong to the project.'});
247
+ res.status(403).send({success: false, msg: 'you dont have the required role.'});
198
248
  }
199
- /**
200
- * Updated by Johnny - 29mar2024 - END
201
- */
249
+ } else { //custom role
250
+
251
+ winston.debug("custom role: "+ userRole);
252
+ return that.hasPermission(permission)(req, res, next);
202
253
 
203
- // if (req.user) equals super admin next()
204
- //res.status(403).send({success: false, msg: 'you dont belong to the project.'});
205
- }
206
-
207
- });
208
-
209
- }
210
-
211
- }
254
+ // if (permission==undefined) {
255
+ // winston.debug("permission is empty go next");
256
+ // return next();
257
+ // }
212
258
 
213
- // unused
214
- hasRoleAsPromise(role, user_id, projectid) {
259
+ // // invece di questo codice richiama hasPermission()
260
+ // var permissions = project_user._doc.rolePermissions;
215
261
 
216
- var that = this;
262
+ // winston.debug("hasPermission permissions", permissions);
263
+
264
+ // winston.debug("hasPermission permission: "+ permission);
217
265
 
218
- return new Promise(function (resolve, reject) {
219
- // project_user_qui_importante
266
+ // if (permissions!=undefined && permissions.length>0) {
267
+ // if (permissions.includes(permission)) {
268
+ // next();
269
+ // }else {
270
+ // res.status(403).send({success: false, msg: 'you dont have the required permission.'});
271
+ // }
272
+ // } else {
273
+ // res.status(403).send({success: false, msg: 'you dont have the required permission. Is is empty'});
274
+ // }
220
275
 
221
- // JWT_HERE
222
- var query = { id_project: req.params.projectid, id_user: req.user._id, status: "active"};
223
- if (req.user.sub && (req.user.sub=="userexternal" || req.user.sub=="guest")) {
224
- query = { id_project: req.params.projectid, uuid_user: req.user._id};
225
- }
226
276
 
227
- Project_user.find(query).
228
- exec(function (err, project_users) {
229
- if (err) {
230
- winston.error(err);
231
- return reject(err);
232
277
  }
233
-
234
-
235
- if (project_users && project_users.length>0) {
236
278
 
237
- var project_user= project_users[0];
279
+ }
280
+
281
+ } else {
282
+
283
+ /**
284
+ * Updated by Johnny - 29mar2024 - START
285
+ */
286
+ // console.log("req.user: ", req.user);
287
+ if (req.user.email === process.env.ADMIN_EMAIL) {
288
+ req.user.attributes = { isSuperadmin: true };
289
+ next();
290
+ } else {
291
+ res.status(403).send({success: false, msg: 'you dont belong to the project.'});
292
+ }
293
+ /**
294
+ * Updated by Johnny - 29mar2024 - END
295
+ */
238
296
 
239
- var userRole = project_user.role;
240
- // winston.debug("userRole", userRole);
241
-
242
- if (!role) {
243
- resolve(project_user);
244
- }else {
245
-
246
- var hierarchicalRoles = that.ROLES[userRole];
247
- // winston.debug("hierarchicalRoles", hierarchicalRoles);
248
-
249
- if ( hierarchicalRoles.includes(role)) {
250
- resolve(project_user);
251
- }else {
252
- reject({success: false, msg: 'you dont have the required role.'});
253
- }
254
- }
255
- } else {
256
-
257
- // if (req.user) equals super admin next()
258
- reject({success: false, msg: 'you dont belong to the project.'});
259
- }
260
-
261
- });
297
+ // if (req.user) equals super admin next()
298
+ //res.status(403).send({success: false, msg: 'you dont belong to the project.'});
299
+ }
262
300
 
263
- });
264
301
 
265
- }
266
-
302
+ }
303
+ }
304
+
267
305
  }
268
306
 
269
307
 
308
+
270
309
  var roleChecker = new RoleChecker();
271
310
  module.exports = roleChecker;
272
311