...
This commit is contained in:
31
reviews/db/schema.cds
Normal file
31
reviews/db/schema.cds
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace sap.capire.reviews;
|
||||
using { User } from '@sap/cds/common';
|
||||
|
||||
// Reviewed subjects can be any entity that is uniquely identified
|
||||
// by a single key element such as a UUID
|
||||
type ReviewedSubject : String(111);
|
||||
|
||||
entity Reviews {
|
||||
key ID : UUID;
|
||||
subject : ReviewedSubject;
|
||||
reviewer : User;
|
||||
rating : Rating;
|
||||
title : String(111);
|
||||
text : String(1111);
|
||||
date : DateTime;
|
||||
likes : Composition of many Likes on likes.review = $self;
|
||||
liked : Integer default 0; // counter for likes as helpful review (count of all _likes belonging to this review)
|
||||
}
|
||||
|
||||
type Rating : Decimal(3,2) enum {
|
||||
Best = 5;
|
||||
Good = 4;
|
||||
Avg = 3;
|
||||
Poor = 2;
|
||||
Worst = 1;
|
||||
}
|
||||
|
||||
entity Likes {
|
||||
key review : Association to Reviews;
|
||||
key user : User;
|
||||
}
|
||||
1
reviews/index.cds
Normal file
1
reviews/index.cds
Normal file
@@ -0,0 +1 @@
|
||||
using from './srv/reviews-service';
|
||||
30
reviews/package.json
Normal file
30
reviews/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@sap/capire-reviews",
|
||||
"version": "1.0.0",
|
||||
"description": "A reuse service providing generic means to add reviews and ratings to target objects, e.g. products.",
|
||||
"repository": "https://github.com/SAP-samples/cloud-cap-samples.git",
|
||||
"license": "SAP SAMPLE CODE LICENSE",
|
||||
"dependencies": {
|
||||
"@sap/cds": "latest",
|
||||
"express": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cds run --in-memory?",
|
||||
"watch": "cds watch"
|
||||
},
|
||||
"files": [
|
||||
"db",
|
||||
"srv",
|
||||
"index.cds"
|
||||
],
|
||||
"cds": {
|
||||
"requires": {
|
||||
"db": {
|
||||
"kind": "sql"
|
||||
},
|
||||
"messaging": {
|
||||
"kind": "file-based-messaging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
reviews/srv/reviews-service.cds
Normal file
41
reviews/srv/reviews-service.cds
Normal file
@@ -0,0 +1,41 @@
|
||||
using { sap.capire.reviews as my } from '../db/schema';
|
||||
namespace sap.capire.reviews;
|
||||
|
||||
service ReviewsService {
|
||||
// Sync API
|
||||
entity Reviews as projection on my.Reviews excluding { likes }
|
||||
action like (review:Reviews.ID); // TODO: can be a bound action in OData
|
||||
action unlike (review:Reviews.ID); // TODO: can be a bound action in OData
|
||||
|
||||
// Async API
|
||||
event reviewed : { subject: Reviews.subject; rating: Decimal(2,1) };
|
||||
|
||||
// Input validation
|
||||
annotate Reviews with {
|
||||
subject @mandatory;
|
||||
title @mandatory;
|
||||
rating @mandatory @assert.enum;
|
||||
}
|
||||
|
||||
// Auto-fill reviewers and review dates
|
||||
annotate Reviews with {
|
||||
reviewer @cds.on.insert:$user;
|
||||
date @cds.on.insert:$now;
|
||||
date @cds.on.update:$now;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Access control restrictions
|
||||
annotate ReviewsService.Reviews with @restrict_:[
|
||||
{ grant:'READ', to:'any' }, // everybody can read reviews
|
||||
{ grant:'CREATE', to:'authenticated-user' }, // users must login to add reviews
|
||||
{ grant:'UPDATE', to:'authenticated-user', where:'reviewer=$user' },
|
||||
{ grant:'DELETE', to:'admin' },
|
||||
];
|
||||
|
||||
annotate ReviewsService with @restrict_:[
|
||||
{ grant:'like', to:'identified-user' },
|
||||
{ grant:'unlike', to:'identified-user', where:'user=$user' },
|
||||
];
|
||||
41
reviews/srv/reviews-service.js
Normal file
41
reviews/srv/reviews-service.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const cds = require ('@sap/cds')
|
||||
module.exports = cds.service.impl (function(){
|
||||
|
||||
// Get the CSN definition for Reviews from the db schema for sub-sequent queries
|
||||
// ( Note: we explicitly specify the namespace to support embedded reuse )
|
||||
const { Reviews, Likes } = this.entities ('sap.capire.reviews')
|
||||
|
||||
// Emit an event to inform subscribers about new avg ratings for reviewed subjects
|
||||
// ( Note: req.on.succeeded ensures we only do that if there's no error )
|
||||
this.after (['CREATE','UPDATE','DELETE'], 'Reviews', async(_,req) => {
|
||||
const {subject} = req.data
|
||||
const {rating} = await cds.transaction(req) .run (
|
||||
SELECT.one (['round(avg(rating),2) as rating']) .from (Reviews) .where ({subject})
|
||||
)
|
||||
req.on ('succeeded', ()=>{
|
||||
console.log ('< emitting:', 'reviewed', { subject, rating })
|
||||
this.emit ('reviewed', { subject, rating })
|
||||
})
|
||||
})
|
||||
|
||||
// Increment counter for reviews considered helpful
|
||||
this.on ('like', (req) => {
|
||||
if (!req.user) return req.reject(400, 'You must be identified to like a review')
|
||||
const {review} = req.data, {user} = req
|
||||
const tx = cds.transaction(req)
|
||||
return tx.run ([
|
||||
INSERT.into (Likes) .entries ({review_ID: review, user: user.id}),
|
||||
UPDATE (Reviews) .set({liked: {'+=': 1}}) .where({ID:review})
|
||||
]).catch(() => req.reject(400, 'You already liked that review'))
|
||||
})
|
||||
|
||||
// Delete a former like by the same user
|
||||
this.on ('unlike', async (req) => {
|
||||
if (!req.user) return req.reject(400, 'You must be identified to remove a former like of yours')
|
||||
const {review} = req.data, {user} = req
|
||||
const tx = cds.transaction(req)
|
||||
const affectedRows = await tx.run (DELETE.from (Likes) .where ({review_ID: review,user: user.id}))
|
||||
if (affectedRows === 1) return tx.run (UPDATE (Reviews) .set ({liked: {'-=': 1}}) .where ({ID:review}))
|
||||
})
|
||||
|
||||
})
|
||||
69
reviews/tests/messaging.test.js
Normal file
69
reviews/tests/messaging.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const _model = __dirname+'/..'
|
||||
const cds = require ('@sap/cds')
|
||||
|
||||
describe('messaging tests', ()=>{
|
||||
|
||||
it ('should bootstrap sqlite in-memory db', async()=>{
|
||||
const db = await cds.deploy (_model) .to ('sqlite::memory:')
|
||||
expect (db.model) .toBeDefined()
|
||||
})
|
||||
|
||||
let srv
|
||||
it ('should serve reviews services', async()=>{
|
||||
srv = await cds.serve('ReviewsService') .from (_model)
|
||||
expect (srv.name) .toMatch ('ReviewsService')
|
||||
})
|
||||
|
||||
let N=0, received=[], M=0
|
||||
it ('should add messaging event handlers', ()=>{
|
||||
srv.on('reviewed', (msg)=> received.push(msg))
|
||||
})
|
||||
|
||||
it ('should add more messaging event handlers', ()=>{
|
||||
srv.on('reviewed', ()=> ++M)
|
||||
})
|
||||
|
||||
it ('should add review', async ()=>{
|
||||
const review = {
|
||||
ID: 111 + (++N), // FIXME: why does the generic handler not fill this in automatically ?!? --> it does so when the request comes in via Postman / OData
|
||||
subject: "201", title: "Captivating", rating: N
|
||||
}
|
||||
const response = await srv.create ('Reviews') .entries (review)
|
||||
expect (response) .toMatchObject (review)
|
||||
},100)
|
||||
|
||||
it ('should add more reviews', ()=> Promise.all ([
|
||||
// REVISIT: mass operation should trigger one message per entry
|
||||
// srv.create('Reviews').entries(
|
||||
// { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N },
|
||||
// { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N },
|
||||
// { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N },
|
||||
// { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N },
|
||||
// ),
|
||||
srv.create ('Reviews') .entries (
|
||||
{ ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }
|
||||
),
|
||||
srv.create ('Reviews') .entries (
|
||||
{ ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }
|
||||
),
|
||||
srv.create ('Reviews') .entries (
|
||||
{ ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }
|
||||
),
|
||||
srv.create ('Reviews') .entries (
|
||||
{ ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }
|
||||
),
|
||||
]) ,100)
|
||||
|
||||
it ('should have received all messages', async()=> {
|
||||
await new Promise((done)=>setImmediate(done))
|
||||
expect(M).toBe(N)
|
||||
expect(received.length).toBe(N)
|
||||
expect(received.map(m=>m.data)).toEqual([
|
||||
{ subject: '201', rating: 1 },
|
||||
{ subject: '201', rating: 1.5 },
|
||||
{ subject: '201', rating: 2 },
|
||||
{ subject: '201', rating: 2.5 },
|
||||
{ subject: '201', rating: 3 },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user