Skip to content

Commit e3cac34

Browse files
committed
Add pusher websocket listener for phone updated events
1 parent 8859924 commit e3cac34

File tree

8 files changed

+123
-17
lines changed

8 files changed

+123
-17
lines changed

android/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ buildscript {
1717
}
1818

1919
plugins {
20-
id 'com.android.application' version '8.7.3' apply false
21-
id 'com.android.library' version '8.7.3' apply false
20+
id 'com.android.application' version '8.8.0' apply false
21+
id 'com.android.library' version '8.8.0' apply false
2222
id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
2323
}
2424

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#Thu Jun 23 15:32:32 EEST 2022
22
distributionBase=GRADLE_USER_HOME
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
44
distributionPath=wrapper/dists
55
zipStorePath=wrapper/dists
66
zipStoreBase=GRADLE_USER_HOME

api/.env.docker

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,10 @@ REDIS_URL=redis://@redis:6379
5151
# [optional] If you would like to use uptrace.dev for distributed tracing, you can set the DSN here.
5252
# This is optional and you can leave it empty if you don't want to use uptrace
5353
UPTRACE_DSN=
54+
55+
56+
# [optional] Websocket configuration for https://pusher.com if you will like to frontend to update in real time
57+
PUSHER_APP_ID=
58+
PUSHER_KEY=
59+
PUSHER_SECRET=
60+
PUSHER_CLUSTER=

api/go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ require (
3535
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177
3636
github.com/patrickmn/go-cache v2.1.0+incompatible
3737
github.com/pkg/errors v0.9.1
38+
github.com/pusher/pusher-http-go/v5 v5.1.1
3839
github.com/redis/go-redis/extra/redisotel/v9 v9.7.1
3940
github.com/redis/go-redis/v9 v9.7.3
4041
github.com/rs/zerolog v1.34.0
@@ -151,13 +152,13 @@ require (
151152
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
152153
go.uber.org/multierr v1.11.0 // indirect
153154
go.uber.org/zap v1.27.0 // indirect
154-
golang.org/x/crypto v0.36.0 // indirect
155+
golang.org/x/crypto v0.37.0 // indirect
155156
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
156157
golang.org/x/net v0.38.0 // indirect
157158
golang.org/x/oauth2 v0.27.0 // indirect
158-
golang.org/x/sync v0.12.0 // indirect
159-
golang.org/x/sys v0.31.0 // indirect
160-
golang.org/x/text v0.23.0 // indirect
159+
golang.org/x/sync v0.13.0 // indirect
160+
golang.org/x/sys v0.32.0 // indirect
161+
golang.org/x/text v0.24.0 // indirect
161162
golang.org/x/time v0.10.0 // indirect
162163
golang.org/x/tools v0.31.0 // indirect
163164
google.golang.org/appengine v1.6.8 // indirect

api/go.sum

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
246246
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
247247
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
248248
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
249+
github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ=
250+
github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8=
249251
github.com/redis/go-redis/extra/rediscmd/v9 v9.7.1 h1:+o7rrBoj54t8fqQSmnwRLdLzp5rps7bW4xiYZp2MBjs=
250252
github.com/redis/go-redis/extra/rediscmd/v9 v9.7.1/go.mod h1:bWIjbxmrAk9eKGg9LSko3oQefoYGyWV4xzNS55PgL60=
251253
github.com/redis/go-redis/extra/redisotel/v9 v9.7.1 h1:LJF39lvUagUpKfL2/gZIp5vHv3AwXt9zOZ/Xual/CzI=
@@ -363,11 +365,12 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
363365
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
364366
golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
365367
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
368+
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
366369
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
367370
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
368371
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
369-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
370-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
372+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
373+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
371374
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
372375
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
373376
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
@@ -378,6 +381,7 @@ golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
378381
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
379382
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
380383
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
384+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
381385
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
382386
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
383387
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -393,10 +397,11 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
393397
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
394398
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
395399
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
396-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
397-
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
400+
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
401+
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
398402
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
399403
golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
404+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
400405
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
401406
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
402407
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -409,8 +414,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
409414
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
410415
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
411416
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
412-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
413-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
417+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
418+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
414419
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
415420
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
416421
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -425,8 +430,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
425430
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
426431
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
427432
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
428-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
429-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
433+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
434+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
430435
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
431436
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
432437
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -457,6 +462,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
457462
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
458463
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
459464
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
465+
gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M=
466+
gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU=
460467
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
461468
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
462469
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

api/pkg/di/container.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"strconv"
1010
"time"
1111

12+
"github.com/pusher/pusher-http-go/v5"
13+
1214
"github.com/NdoleStudio/httpsms/docs"
1315

1416
otelMetric "go.opentelemetry.io/otel/metric"
@@ -149,6 +151,7 @@ func NewContainer(projectID string, version string) (container *Container) {
149151
container.RegisterPhoneAPIKeyListeners()
150152

151153
container.RegisterMarketingListeners()
154+
container.RegisterWebsocketListeners()
152155

153156
// this has to be last since it registers the /* route
154157
container.RegisterSwaggerRoutes()
@@ -1142,6 +1145,18 @@ func (container *Container) LemonsqueezyClient() (client *lemonsqueezy.Client) {
11421145
)
11431146
}
11441147

1148+
// PusherClient creates a new instance of pusher.Client
1149+
func (container *Container) PusherClient() (client *pusher.Client) {
1150+
container.logger.Debug(fmt.Sprintf("creating %T", client))
1151+
return &pusher.Client{
1152+
AppID: os.Getenv("PUSHER_APP_ID"),
1153+
Key: os.Getenv("PUSHER_KEY"),
1154+
Secret: os.Getenv("PUSHER_SECRET"),
1155+
Cluster: os.Getenv("PUSHER_CLUSTER"),
1156+
Secure: true,
1157+
}
1158+
}
1159+
11451160
// DiscordClient creates a new instance of discord.Client
11461161
func (container *Container) DiscordClient() (client *discord.Client) {
11471162
container.logger.Debug(fmt.Sprintf("creating %T", client))
@@ -1316,6 +1331,26 @@ func (container *Container) RegisterPhoneAPIKeyListeners() {
13161331
}
13171332
}
13181333

1334+
// RegisterWebsocketListeners registers event listeners for listeners.WebsocketListener
1335+
func (container *Container) RegisterWebsocketListeners() {
1336+
container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebsocketListener{}))
1337+
1338+
if os.Getenv("PUSHER_SECRET") == "" {
1339+
container.logger.Warn(stacktrace.NewError("skipping websocket listeners because the PUSHER_SECRET env variable is not set"))
1340+
return
1341+
}
1342+
1343+
_, routes := listeners.NewWebsocketListener(
1344+
container.Logger(),
1345+
container.Tracer(),
1346+
container.PusherClient(),
1347+
)
1348+
1349+
for event, handler := range routes {
1350+
container.EventDispatcher().Subscribe(event, handler)
1351+
}
1352+
}
1353+
13191354
// RegisterWebhookListeners registers event listeners for listeners.WebhookListener
13201355
func (container *Container) RegisterWebhookListeners() {
13211356
container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebhookListener{}))

api/pkg/handlers/phone_api_key_handler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (h *PhoneAPIKeyHandler) Store(c *fiber.Ctx) error {
103103
// @Failure 404 {object} responses.NotFound
104104
// @Failure 422 {object} responses.UnprocessableEntity
105105
// @Failure 500 {object} responses.InternalServerError
106-
// @Router /messages/{phoneAPIKeyID} [delete]
106+
// @Router /api-keys/{phoneAPIKeyID} [delete]
107107
func (h *PhoneAPIKeyHandler) Delete(c *fiber.Ctx) error {
108108
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
109109
defer span.End()
@@ -144,7 +144,7 @@ func (h *PhoneAPIKeyHandler) Delete(c *fiber.Ctx) error {
144144
// @Failure 404 {object} responses.NotFound
145145
// @Failure 422 {object} responses.UnprocessableEntity
146146
// @Failure 500 {object} responses.InternalServerError
147-
// @Router /messages/{phoneAPIKeyID}/phones/{phoneID} [delete]
147+
// @Router /api-keys/{phoneAPIKeyID}/phones/{phoneID} [delete]
148148
func (h *PhoneAPIKeyHandler) DeletePhone(c *fiber.Ctx) error {
149149
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
150150
defer span.End()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package listeners
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
cloudevents "github.com/cloudevents/sdk-go/v2"
8+
"github.com/palantir/stacktrace"
9+
10+
"github.com/NdoleStudio/httpsms/pkg/events"
11+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
12+
"github.com/pusher/pusher-http-go/v5"
13+
)
14+
15+
// WebsocketListener handles cloud events that send a websocket event to the frontend
16+
type WebsocketListener struct {
17+
logger telemetry.Logger
18+
tracer telemetry.Tracer
19+
client *pusher.Client
20+
}
21+
22+
// NewWebsocketListener creates a new instance of WebsocketListener
23+
func NewWebsocketListener(
24+
logger telemetry.Logger,
25+
tracer telemetry.Tracer,
26+
client *pusher.Client,
27+
) (l *WebsocketListener, routes map[string]events.EventListener) {
28+
l = &WebsocketListener{
29+
logger: logger.WithService(fmt.Sprintf("%T", l)),
30+
tracer: tracer,
31+
client: client,
32+
}
33+
34+
return l, map[string]events.EventListener{
35+
events.EventTypePhoneUpdated: l.onPhoneUpdated,
36+
}
37+
}
38+
39+
// onPhoneUpdated handles the events.EventTypePhoneUpdated event
40+
func (listener *WebsocketListener) onPhoneUpdated(ctx context.Context, event cloudevents.Event) error {
41+
ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger)
42+
defer span.End()
43+
44+
var payload events.PhoneUpdatedPayload
45+
if err := event.DataAs(&payload); err != nil {
46+
msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)
47+
return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
48+
}
49+
50+
if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event); err != nil {
51+
msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)
52+
return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
53+
}
54+
55+
return nil
56+
}

0 commit comments

Comments
 (0)